mirror of
https://github.com/Mesh-LLM/mesh-llm.git
synced 2026-08-08 22:23:19 -04:00
feat: native rust SDK for rust consumers. (#736)
* a run at SDK, tested with a client * Use in-process shutdown for embedded SDK * Satisfy clippy for embedded shutdown plumbing * Document embedded Rust SDK usage * Add public Rust SDK crate * Tighten embedded SDK lifecycle * Address embedded SDK review feedback * Document native runtime packaging direction * Document runtime CLI namespace * Document runtime CLI UX expectations * Document runtime diagnostics under doctor * Add Windows PowerShell installer * Document recommended runtime install flow * Add native runtime resolver foundation * Wire native runtime release installs * Document native runtime crate * Load versioned native runtimes dynamically * Fix embedded SDK output manager reset * Expose SDK mesh admission controls * Split SDK runtime mapping assertions * Tighten SDK docs and config module * Clarify native runtime SDK TODOs * Stabilize native log note test * Expose native runtime install SDK * Re-export native runtime APIs from SDK crate * Add embedded SDK knobs for Sprout relay mesh (#782) Two opt-in seams the Sprout v1 mesh integration needs: - disable_iroh_relays(bool): when true, embedded runtime selects an explicitly disabled relay policy, which uses RelayMode::Disabled, skips public relay URL fallback, skips raw STUN, and avoids the 5s endpoint.online() wait that cannot succeed without a home relay. Default false preserves existing behavior. - EmbeddedNodeHandle::join_token(token): forwards an invite token over the runtime control channel to node.join_with_retry so an already-running embedded node can dial a new EndpointAddr without restart. Handled in both auto and passive/client runtime loops. Purely additive; existing defaults and startup join_tokens behavior remain unchanged. Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> * Fix relay policy test visibility * Make SDK publishable and align language bindings (#771) * Split SDK native runtime publish surface * Split CLI and TUI support crates * Move CLI parser surface into mesh-llm-cli * Extract shared mesh event surface * Move standalone command handlers out of host runtime * Move benchmark and plugin commands out of host runtime * Finish plugin command extraction * Extract auth identity ownership * Move remaining standalone commands out of host runtime * Move model store into model-hf * Move CLI commands out of host runtime * Decouple host runtime from CLI and TUI crates * Expose embedded node SDK facade * Keep client identity dependencies pure * Simplify Rust SDK feature surface * Keep SDK client feature runtime-free * Expose SDK client API base override * Use direct mesh SDK client transport * Remove API base URL client builder shim * Align language SDKs with Rust SDK facade * Document SDK client and serving modes * Fix SDK smoke runtime setup * Package SDK console assets * Fix dynamic runtime CI setup * Restructure SDK docs by language * Fix SDK smoke package loading * Fix native runtime bundle resolution * Add structured native runtime backend metadata * Harden Kotlin native runtime smoke resolution * Fix mesh-llm-sdk clippy imports * Retry smoke model downloads --------- Co-authored-by: James Dumay <jameswdumay@gmail.com> Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
ee67364163
commit
97c0cad991
248 changed files with 23415 additions and 9762 deletions
2
.github/actions/compute-changes/action.yml
vendored
2
.github/actions/compute-changes/action.yml
vendored
|
|
@ -149,7 +149,7 @@ runs:
|
|||
DIRECT_SDK_INPUTS=$(echo "$CHANGED_FILES" | grep -E '(^sdk/|^Package\.swift$|^scripts/ci-(native|kotlin|swift)-sdk-smoke\.sh$|^scripts/ci-sdk-fixture\.sh$|^\.github/workflows/sdk-smoke\.yml$)' || true)
|
||||
if [[ -n "$DIRECT_SDK_INPUTS" ]]; then
|
||||
SDK_SMOKE_REQUIRED="true"
|
||||
elif echo "$AFFECTED_CRATES" | jq -e 'index("mesh-llm-client") or index("mesh-llm-api-client") or index("mesh-llm-api-server") or index("mesh-llm-config") or index("mesh-llm-console-server") or index("mesh-llm-ffi") or index("mesh-llm-protocol") or index("mesh-llm-routing") or index("mesh-llm-types")' >/dev/null; then
|
||||
elif echo "$AFFECTED_CRATES" | jq -e 'index("mesh-llm-client") or index("mesh-llm-api-client") or index("mesh-llm-api-server") or index("mesh-llm-config") or index("mesh-llm-console-server") or index("mesh-llm-ffi") or index("mesh-llm-native-runtime") or index("mesh-llm-protocol") or index("mesh-llm-routing") or index("mesh-llm-types")' >/dev/null; then
|
||||
SDK_SMOKE_REQUIRED="true"
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -67,7 +67,10 @@ runs:
|
|||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.models
|
||||
curl -fSL "${{ inputs.model_url }}" -o "$HOME/.models/${{ inputs.model_file }}"
|
||||
curl --fail --location --show-error \
|
||||
--retry 6 --retry-delay 10 --retry-all-errors \
|
||||
"${{ inputs.model_url }}" \
|
||||
-o "$HOME/.models/${{ inputs.model_file }}"
|
||||
ls -lh "$HOME/.models/${{ inputs.model_file }}"
|
||||
|
||||
- name: Save integration model cache
|
||||
|
|
|
|||
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
|
|
@ -72,17 +72,30 @@ jobs:
|
|||
- 'crates/mesh-llm-api-client/**'
|
||||
- 'crates/mesh-llm-api-server/**'
|
||||
- 'crates/mesh-llm-config/**'
|
||||
- 'crates/mesh-llm-commands/**'
|
||||
- 'crates/mesh-llm-events/**'
|
||||
- 'crates/mesh-llm-hardware-profile/**'
|
||||
- 'crates/mesh-llm-runtime-install/**'
|
||||
- 'crates/mesh-llm-sdk/**'
|
||||
- 'crates/mesh-llm-cli/**'
|
||||
- 'crates/mesh-llm-embedded-runtime/**'
|
||||
- 'crates/mesh-llm-tui/**'
|
||||
- 'crates/mesh-llm-console-server/**'
|
||||
- 'crates/mesh-llm-ffi/**'
|
||||
- 'crates/mesh-llm-nodejs/**'
|
||||
- 'crates/mesh-client/**'
|
||||
- 'crates/mesh-llm-identity/**'
|
||||
- 'crates/mesh-llm-native-runtime/**'
|
||||
- 'crates/mesh-llm-protocol/**'
|
||||
- 'crates/mesh-llm-routing/**'
|
||||
- 'crates/mesh-llm-types/**'
|
||||
- 'sdk/**'
|
||||
- 'Package.swift'
|
||||
- 'scripts/ci-native-sdk-smoke.sh'
|
||||
- 'scripts/ci-rust-sdk-smoke.sh'
|
||||
- 'scripts/ci-prepare-native-runtime.sh'
|
||||
- 'scripts/ci-install-native-runtime.sh'
|
||||
- 'scripts/package-sdk-console-assets.sh'
|
||||
- 'scripts/verify-sdk-console-assets.sh'
|
||||
- 'scripts/ci-kotlin-sdk-smoke.sh'
|
||||
- 'scripts/ci-swift-sdk-smoke.sh'
|
||||
- 'scripts/ci-sdk-fixture.sh'
|
||||
|
|
@ -247,7 +260,7 @@ jobs:
|
|||
run: scripts/build-llama.sh
|
||||
|
||||
- name: SDK and API crate tests
|
||||
if: ${{ needs.changes.outputs.all_rust == 'true' || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-client') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-api-client') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-api-server') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-config') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-console-server') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-ffi') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-nodejs') }}
|
||||
if: ${{ needs.changes.outputs.all_rust == 'true' || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-client') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-api-client') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-api-server') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-config') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-commands') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-events') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-hardware-profile') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-runtime-install') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-sdk') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-cli') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-tui') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-embedded-runtime') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-console-server') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-ffi') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-nodejs') }}
|
||||
env:
|
||||
AFFECTED: ${{ needs.changes.outputs.affected_crates }}
|
||||
ALL_RUST: ${{ needs.changes.outputs.all_rust }}
|
||||
|
|
@ -257,7 +270,7 @@ jobs:
|
|||
[ "$ALL_RUST" = "true" ] || jq -e --arg crate "$crate" 'index($crate) != null' <<<"$AFFECTED" >/dev/null
|
||||
}
|
||||
|
||||
for c in mesh-llm-client mesh-llm-api-client mesh-llm-api-server mesh-llm-config mesh-llm-console-server mesh-llm-ffi mesh-llm-nodejs; do
|
||||
for c in mesh-llm-client mesh-llm-api-client mesh-llm-api-server mesh-llm-config mesh-llm-commands mesh-llm-events mesh-llm-hardware-profile mesh-llm-runtime-install mesh-llm-cli mesh-llm-tui mesh-llm-embedded-runtime mesh-llm-sdk mesh-llm-console-server mesh-llm-ffi mesh-llm-nodejs; do
|
||||
if should_test "$c"; then
|
||||
cargo test -p "$c"
|
||||
else
|
||||
|
|
@ -427,12 +440,12 @@ jobs:
|
|||
smoke_script: scripts/ci-two-node-client-serving-smoke.sh
|
||||
timeout_minutes: 20
|
||||
|
||||
native_sdk_smoke:
|
||||
rust_sdk_smoke:
|
||||
needs: [changes, linux, inference_smoke_tests]
|
||||
if: ${{ needs.inference_smoke_tests.result == 'success' && (github.event_name == 'workflow_dispatch' || needs.changes.outputs.sdk == 'true') && needs.changes.outputs.docs_only != 'true' }}
|
||||
uses: ./.github/workflows/sdk-smoke.yml
|
||||
with:
|
||||
sdk_kind: native
|
||||
sdk_kind: rust
|
||||
artifact_name: ci-linux-inference-binaries
|
||||
artifact_path: ci-artifacts/linux
|
||||
staged_binary_path: target/debug/mesh-llm
|
||||
|
|
|
|||
8
.github/workflows/docker-precheck.yml
vendored
8
.github/workflows/docker-precheck.yml
vendored
|
|
@ -63,13 +63,21 @@ jobs:
|
|||
grep -qE '^\s*COPY\s+Cargo\.toml\s+Cargo\.lock\s+\./?$' "$file"
|
||||
for path in \
|
||||
crates/mesh-llm/ \
|
||||
crates/mesh-llm-cli/ \
|
||||
crates/mesh-llm-commands/ \
|
||||
crates/mesh-llm-events/ \
|
||||
crates/mesh-llm-hardware-profile/ \
|
||||
crates/mesh-llm-identity/ \
|
||||
crates/mesh-llm-native-runtime/ \
|
||||
crates/mesh-llm-protocol/ \
|
||||
crates/mesh-llm-routing/ \
|
||||
crates/mesh-llm-runtime-install/ \
|
||||
crates/mesh-llm-guardrails/ \
|
||||
crates/mesh-llm-types/ \
|
||||
crates/mesh-llm-config/ \
|
||||
crates/mesh-llm-console-server/ \
|
||||
crates/mesh-llm-embedded-runtime/ \
|
||||
crates/mesh-llm-tui/ \
|
||||
crates/mesh-llm-plugin/ \
|
||||
crates/mesh-llm-skills/ \
|
||||
crates/mesh-llm-plugin-manager/ \
|
||||
|
|
|
|||
23
.github/workflows/pr_builds.yml
vendored
23
.github/workflows/pr_builds.yml
vendored
|
|
@ -76,17 +76,30 @@ jobs:
|
|||
- 'crates/mesh-llm-api-client/**'
|
||||
- 'crates/mesh-llm-api-server/**'
|
||||
- 'crates/mesh-llm-config/**'
|
||||
- 'crates/mesh-llm-commands/**'
|
||||
- 'crates/mesh-llm-events/**'
|
||||
- 'crates/mesh-llm-hardware-profile/**'
|
||||
- 'crates/mesh-llm-runtime-install/**'
|
||||
- 'crates/mesh-llm-sdk/**'
|
||||
- 'crates/mesh-llm-cli/**'
|
||||
- 'crates/mesh-llm-embedded-runtime/**'
|
||||
- 'crates/mesh-llm-tui/**'
|
||||
- 'crates/mesh-llm-console-server/**'
|
||||
- 'crates/mesh-llm-ffi/**'
|
||||
- 'crates/mesh-llm-nodejs/**'
|
||||
- 'crates/mesh-client/**'
|
||||
- 'crates/mesh-llm-identity/**'
|
||||
- 'crates/mesh-llm-native-runtime/**'
|
||||
- 'crates/mesh-llm-protocol/**'
|
||||
- 'crates/mesh-llm-routing/**'
|
||||
- 'crates/mesh-llm-types/**'
|
||||
- 'sdk/**'
|
||||
- 'Package.swift'
|
||||
- 'scripts/ci-native-sdk-smoke.sh'
|
||||
- 'scripts/ci-rust-sdk-smoke.sh'
|
||||
- 'scripts/ci-prepare-native-runtime.sh'
|
||||
- 'scripts/ci-install-native-runtime.sh'
|
||||
- 'scripts/package-sdk-console-assets.sh'
|
||||
- 'scripts/verify-sdk-console-assets.sh'
|
||||
- 'scripts/ci-kotlin-sdk-smoke.sh'
|
||||
- 'scripts/ci-swift-sdk-smoke.sh'
|
||||
- 'scripts/ci-sdk-fixture.sh'
|
||||
|
|
@ -496,14 +509,14 @@ jobs:
|
|||
run: scripts/build-llama.sh
|
||||
|
||||
- name: SDK and API crate tests
|
||||
if: ${{ matrix.group == 'sdk-api' && (needs.changes.outputs.all_rust == 'true' || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-client') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-api-client') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-api-server') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-config') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-console-server') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-ffi') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-nodejs')) }}
|
||||
if: ${{ matrix.group == 'sdk-api' && (needs.changes.outputs.all_rust == 'true' || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-client') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-api-client') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-api-server') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-config') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-commands') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-events') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-hardware-profile') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-runtime-install') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-sdk') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-cli') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-tui') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-embedded-runtime') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-console-server') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-ffi') || contains(fromJson(needs.changes.outputs.affected_crates || '[]'), 'mesh-llm-nodejs')) }}
|
||||
run: |
|
||||
should_test() {
|
||||
local crate="$1"
|
||||
[ "$ALL_RUST" = "true" ] || jq -e --arg crate "$crate" 'index($crate) != null' <<<"$AFFECTED" >/dev/null
|
||||
}
|
||||
|
||||
for c in mesh-llm-client mesh-llm-api-client mesh-llm-api-server mesh-llm-config mesh-llm-console-server mesh-llm-ffi mesh-llm-nodejs; do
|
||||
for c in mesh-llm-client mesh-llm-api-client mesh-llm-api-server mesh-llm-config mesh-llm-commands mesh-llm-events mesh-llm-hardware-profile mesh-llm-runtime-install mesh-llm-cli mesh-llm-tui mesh-llm-embedded-runtime mesh-llm-sdk mesh-llm-console-server mesh-llm-ffi mesh-llm-nodejs; do
|
||||
if should_test "$c"; then
|
||||
cargo test -p "$c"
|
||||
else
|
||||
|
|
@ -682,12 +695,12 @@ jobs:
|
|||
smoke_script: scripts/ci-two-node-split-smoke.sh
|
||||
timeout_minutes: 25
|
||||
|
||||
native_sdk_smoke:
|
||||
rust_sdk_smoke:
|
||||
needs: [changes, linux_cpu_artifact]
|
||||
if: ${{ needs.linux_cpu_artifact.result == 'success' && needs.changes.outputs.linux_inference_artifact_required == 'true' && needs.changes.outputs.sdk_smoke_required == 'true' && needs.changes.outputs.docs_only != 'true' }}
|
||||
uses: ./.github/workflows/sdk-smoke.yml
|
||||
with:
|
||||
sdk_kind: native
|
||||
sdk_kind: rust
|
||||
artifact_name: ci-linux-inference-binaries
|
||||
artifact_path: ci-artifacts/linux
|
||||
staged_binary_path: target/debug/mesh-llm
|
||||
|
|
|
|||
114
.github/workflows/release.yml
vendored
114
.github/workflows/release.yml
vendored
|
|
@ -242,6 +242,68 @@ jobs:
|
|||
dist/native-sdk-crates/*/target/package/*.crate
|
||||
if-no-files-found: error
|
||||
|
||||
build_native_runtime:
|
||||
name: Build native runtime ${{ matrix.name }}
|
||||
needs: metadata
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS aarch64 Metal
|
||||
os: blacksmith-6vcpu-macos-15
|
||||
backend: metal
|
||||
target: aarch64-apple-darwin
|
||||
artifact_suffix: darwin-aarch64-metal
|
||||
- name: Linux x86_64 CPU
|
||||
os: blacksmith-4vcpu-ubuntu-2404
|
||||
backend: cpu
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact_suffix: linux-x86_64-cpu
|
||||
env:
|
||||
LLAMA_STAGE_BACKEND: ${{ matrix.backend }}
|
||||
MESH_NATIVE_RUNTIME_TARGET: ${{ matrix.target }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: mozilla-actions/sccache-action@v0.0.9
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential cmake ninja-build pkg-config libssl-dev libdbus-1-dev curl lld
|
||||
|
||||
- name: Install macOS dependencies
|
||||
if: runner.os == 'macOS'
|
||||
run: brew install cmake ninja lld
|
||||
|
||||
- name: Prepare dispatched release version
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.metadata.outputs.tag }}
|
||||
run: scripts/release-version.sh "$RELEASE_TAG"
|
||||
|
||||
- name: Package native runtime
|
||||
run: |
|
||||
scripts/package-native-runtime.sh \
|
||||
--build \
|
||||
--backend "${{ matrix.backend }}" \
|
||||
--target "${{ matrix.target }}" \
|
||||
--out dist/native-runtimes
|
||||
|
||||
- name: Verify native runtime artifact
|
||||
run: scripts/verify-native-runtime-package.sh dist/native-runtimes/*.tar.gz
|
||||
|
||||
- name: Upload native runtime
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: release-native-runtime-${{ matrix.artifact_suffix }}
|
||||
path: |
|
||||
dist/native-runtimes/*.tar.gz
|
||||
dist/native-runtimes/*.sha256
|
||||
if-no-files-found: error
|
||||
|
||||
build_swift_sdk_artifact:
|
||||
name: Build Swift SDK XCFramework
|
||||
needs: metadata
|
||||
|
|
@ -251,6 +313,18 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
.github/cache-version.txt
|
||||
crates/mesh-llm-ui/pnpm-lock.yaml
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: mozilla-actions/sccache-action@v0.0.9
|
||||
|
|
@ -264,6 +338,15 @@ jobs:
|
|||
RELEASE_TAG: ${{ needs.metadata.outputs.tag }}
|
||||
run: scripts/release-version.sh "$RELEASE_TAG"
|
||||
|
||||
- name: Prepare Swift console resources
|
||||
run: |
|
||||
scripts/package-sdk-console-assets.sh --sdk swift
|
||||
scripts/verify-sdk-console-assets.sh --sdk swift
|
||||
|
||||
- name: Verify tagged Swift console resources
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
run: git ls-files --error-unmatch sdk/swift/Sources/MeshLLM/Resources/Console/index.html
|
||||
|
||||
- name: Build SwiftPM binary artifact
|
||||
run: |
|
||||
sdk/swift/scripts/build-xcframework.sh
|
||||
|
|
@ -734,6 +817,7 @@ jobs:
|
|||
- build
|
||||
- inference_smoke_tests
|
||||
- build_native_sdk_runtime
|
||||
- build_native_runtime
|
||||
- build_swift_sdk_artifact
|
||||
- build_linux_arm64
|
||||
- build_linux_aarch64_cuda
|
||||
|
|
@ -742,14 +826,27 @@ jobs:
|
|||
- build_linux_vulkan
|
||||
- build_windows_cpu
|
||||
- build_windows_gpu
|
||||
if: ${{ always() && needs.metadata.result == 'success' && needs.metadata.outputs.canary != 'true' && needs.build.result == 'success' && needs.inference_smoke_tests.result == 'success' && needs.build_native_sdk_runtime.result == 'success' && needs.build_swift_sdk_artifact.result == 'success' && needs.build_linux_arm64.result == 'success' && (needs.build_linux_aarch64_cuda.result == 'success' || needs.build_linux_aarch64_cuda.result == 'skipped') && (needs.build_linux_cuda.result == 'success' || needs.build_linux_cuda.result == 'skipped') && (needs.build_linux_rocm.result == 'success' || needs.build_linux_rocm.result == 'skipped') && (needs.build_linux_vulkan.result == 'success' || needs.build_linux_vulkan.result == 'skipped') && needs.build_windows_cpu.result == 'success' && (needs.build_windows_gpu.result == 'success' || needs.build_windows_gpu.result == 'skipped') }}
|
||||
if: ${{ always() && needs.metadata.result == 'success' && needs.metadata.outputs.canary != 'true' && needs.build.result == 'success' && needs.inference_smoke_tests.result == 'success' && needs.build_native_sdk_runtime.result == 'success' && needs.build_native_runtime.result == 'success' && needs.build_swift_sdk_artifact.result == 'success' && needs.build_linux_arm64.result == 'success' && (needs.build_linux_aarch64_cuda.result == 'success' || needs.build_linux_aarch64_cuda.result == 'skipped') && (needs.build_linux_cuda.result == 'success' || needs.build_linux_cuda.result == 'skipped') && (needs.build_linux_rocm.result == 'success' || needs.build_linux_rocm.result == 'skipped') && (needs.build_linux_vulkan.result == 'success' || needs.build_linux_vulkan.result == 'skipped') && needs.build_windows_cpu.result == 'success' && (needs.build_windows_gpu.result == 'success' || needs.build_windows_gpu.result == 'skipped') }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
.github/cache-version.txt
|
||||
crates/mesh-llm-ui/pnpm-lock.yaml
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
|
||||
|
|
@ -763,6 +860,16 @@ jobs:
|
|||
- name: Remove smoke-only binary
|
||||
run: rm -f release-artifacts/mesh-llm
|
||||
|
||||
- name: Generate native runtime release manifest
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.metadata.outputs.tag }}
|
||||
run: |
|
||||
scripts/generate-native-runtime-release-manifest.sh \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--out release-artifacts/native-runtimes.json \
|
||||
release-artifacts/meshllm-native-runtime-*.tar.gz
|
||||
|
||||
- name: Download generated SwiftPM manifest
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/download-artifact@v7
|
||||
|
|
@ -782,9 +889,12 @@ jobs:
|
|||
fi
|
||||
scripts/release-version.sh "$RELEASE_TAG"
|
||||
cp generated-swift-manifest/Package.swift Package.swift
|
||||
scripts/package-sdk-console-assets.sh --sdk all
|
||||
scripts/verify-sdk-console-assets.sh --sdk all
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add Cargo.toml Cargo.lock crates/*/Cargo.toml tools/*/Cargo.toml sdk/kotlin/build.gradle.kts Package.swift
|
||||
git add -f sdk/node/console sdk/swift/Sources/MeshLLM/Resources/Console sdk/kotlin/src/main/resources/mesh-llm/console
|
||||
if git diff --cached --quiet; then
|
||||
echo "Release source files already match $RELEASE_TAG"
|
||||
else
|
||||
|
|
|
|||
18
.github/workflows/sdk-smoke.yml
vendored
18
.github/workflows/sdk-smoke.yml
vendored
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
if: ${{ inputs.sdk_kind == 'native' }}
|
||||
if: ${{ inputs.sdk_kind == 'rust' }}
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
|
@ -65,6 +65,16 @@ jobs:
|
|||
distribution: temurin
|
||||
java-version: '21'
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: crates/mesh-llm-ui/pnpm-lock.yaml
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux SDK dependencies
|
||||
|
|
@ -106,9 +116,9 @@ jobs:
|
|||
cache_key_prefix: ${{ inputs.cache_key_prefix }}
|
||||
save_model_cache: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Native SDK smoke test
|
||||
if: ${{ inputs.sdk_kind == 'native' }}
|
||||
run: scripts/ci-native-sdk-smoke.sh "${{ inputs.staged_binary_path }}" "${{ inputs.artifact_path }}" "$HOME/.models/${{ inputs.model_file }}"
|
||||
- name: Rust SDK smoke test
|
||||
if: ${{ inputs.sdk_kind == 'rust' }}
|
||||
run: scripts/ci-rust-sdk-smoke.sh "${{ inputs.staged_binary_path }}" "${{ inputs.artifact_path }}" "$HOME/.models/${{ inputs.model_file }}"
|
||||
|
||||
- name: Kotlin SDK smoke test
|
||||
if: ${{ inputs.sdk_kind == 'kotlin' }}
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -26,6 +26,12 @@ dist/llama-stage-static/
|
|||
sdk/kotlin/src/main/kotlin/uniffi/
|
||||
sdk/kotlin/example/example-jvm/src/main/kotlin/uniffi/
|
||||
sdk/node/native/
|
||||
sdk/node/console/*
|
||||
!sdk/node/console/.gitkeep
|
||||
sdk/swift/Sources/MeshLLM/Resources/Console/*
|
||||
!sdk/swift/Sources/MeshLLM/Resources/Console/.gitkeep
|
||||
sdk/kotlin/src/main/resources/mesh-llm/console/*
|
||||
!sdk/kotlin/src/main/resources/mesh-llm/console/.gitkeep
|
||||
sdk/swift/Sources/MeshLLM/Generated/*
|
||||
!sdk/swift/Sources/MeshLLM/Generated/mesh_ffi.swift
|
||||
sdk/swift/Generated/FFI/
|
||||
|
|
|
|||
177
Cargo.lock
generated
177
Cargo.lock
generated
|
|
@ -3759,15 +3759,27 @@ dependencies = [
|
|||
name = "mesh-llm"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"hex",
|
||||
"mesh-llm-cli",
|
||||
"mesh-llm-client",
|
||||
"mesh-llm-commands",
|
||||
"mesh-llm-host-runtime",
|
||||
"mesh-llm-plugin",
|
||||
"mesh-llm-plugin-manager",
|
||||
"mesh-llm-system",
|
||||
"mesh-llm-tui",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"tabwriter",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3790,6 +3802,16 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-cli"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"mesh-llm-events",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-client"
|
||||
version = "0.68.0"
|
||||
|
|
@ -3821,6 +3843,40 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-commands"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"hex",
|
||||
"hf-hub",
|
||||
"iroh",
|
||||
"json5",
|
||||
"mesh-llm-cli",
|
||||
"mesh-llm-identity",
|
||||
"mesh-llm-native-runtime",
|
||||
"mesh-llm-plugin-manager",
|
||||
"mesh-llm-runtime-install",
|
||||
"mesh-llm-system",
|
||||
"mesh-llm-tui",
|
||||
"model-package",
|
||||
"model-ref",
|
||||
"rand 0.10.1",
|
||||
"reqwest 0.12.28",
|
||||
"rpassword",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"url",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-config"
|
||||
version = "0.68.0"
|
||||
|
|
@ -3845,14 +3901,30 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-embedded-runtime"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mesh-llm-host-runtime",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-events"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-ffi"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"mesh-llm-api-server",
|
||||
"mesh-llm-console-server",
|
||||
"mesh-llm-host-runtime",
|
||||
"mesh-llm-node",
|
||||
"mesh-llm-sdk",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uniffi",
|
||||
|
|
@ -3878,13 +3950,18 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-hardware-profile"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"mesh-llm-native-runtime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-host-runtime"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"ansi-to-tui",
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum",
|
||||
|
|
@ -3897,6 +3974,7 @@ dependencies = [
|
|||
"crypto_box",
|
||||
"dirs",
|
||||
"ed25519-dalek",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hf-hub",
|
||||
|
|
@ -3912,18 +3990,22 @@ dependencies = [
|
|||
"mesh-llm-api-server",
|
||||
"mesh-llm-client",
|
||||
"mesh-llm-config",
|
||||
"mesh-llm-events",
|
||||
"mesh-llm-guardrails",
|
||||
"mesh-llm-identity",
|
||||
"mesh-llm-native-runtime",
|
||||
"mesh-llm-node",
|
||||
"mesh-llm-plugin",
|
||||
"mesh-llm-plugin-manager",
|
||||
"mesh-llm-protocol",
|
||||
"mesh-llm-routing",
|
||||
"mesh-llm-runtime-install",
|
||||
"mesh-llm-system",
|
||||
"mesh-llm-types",
|
||||
"mesh-llm-ui",
|
||||
"mesh-mixture-of-agents",
|
||||
"model-artifact",
|
||||
"model-hf",
|
||||
"model-package",
|
||||
"model-ref",
|
||||
"model-resolver",
|
||||
|
|
@ -3934,7 +4016,6 @@ dependencies = [
|
|||
"opentelemetry_sdk",
|
||||
"prost",
|
||||
"rand 0.10.1",
|
||||
"ratatui",
|
||||
"regex-lite",
|
||||
"reqwest 0.12.28",
|
||||
"rmcp",
|
||||
|
|
@ -3953,6 +4034,7 @@ dependencies = [
|
|||
"skippy-server",
|
||||
"skippy-topology",
|
||||
"tabwriter",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
|
@ -3970,14 +4052,33 @@ dependencies = [
|
|||
name = "mesh-llm-identity"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"crypto_box",
|
||||
"dirs",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"keyring",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.18",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-native-runtime"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3998,9 +4099,7 @@ dependencies = [
|
|||
name = "mesh-llm-nodejs"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"mesh-llm-api-server",
|
||||
"mesh-llm-console-server",
|
||||
"mesh-llm-host-runtime",
|
||||
"mesh-llm-sdk",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
|
|
@ -4060,6 +4159,42 @@ dependencies = [
|
|||
"iroh",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-runtime-install"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"mesh-llm-hardware-profile",
|
||||
"mesh-llm-native-runtime",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"skippy-ffi",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-sdk"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mesh-llm-api-client",
|
||||
"mesh-llm-api-server",
|
||||
"mesh-llm-console-server",
|
||||
"mesh-llm-embedded-runtime",
|
||||
"mesh-llm-runtime-install",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-skills"
|
||||
version = "0.68.0"
|
||||
|
|
@ -4102,6 +4237,22 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-tui"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"ansi-to-tui",
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"chrono",
|
||||
"crossterm 0.28.1",
|
||||
"mesh-llm-events",
|
||||
"ratatui",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-types"
|
||||
version = "0.68.0"
|
||||
|
|
@ -4210,10 +4361,15 @@ version = "0.68.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"hf-hub",
|
||||
"model-artifact",
|
||||
"model-ref",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
|
@ -7252,6 +7408,9 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "skippy-ffi"
|
||||
version = "0.68.0"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skippy-metrics"
|
||||
|
|
|
|||
11
Cargo.toml
11
Cargo.toml
|
|
@ -1,16 +1,25 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"crates/mesh-llm",
|
||||
"crates/mesh-llm-cli",
|
||||
"crates/mesh-llm-commands",
|
||||
"crates/mesh-llm-config",
|
||||
"crates/mesh-llm-events",
|
||||
"crates/mesh-llm-gpu-bench",
|
||||
"crates/mesh-llm-host-runtime",
|
||||
"crates/mesh-llm-hardware-profile",
|
||||
"crates/mesh-llm-identity",
|
||||
"crates/mesh-llm-native-runtime",
|
||||
"crates/mesh-llm-protocol",
|
||||
"crates/mesh-llm-routing",
|
||||
"crates/mesh-llm-runtime-install",
|
||||
"crates/mesh-llm-sdk",
|
||||
"crates/mesh-llm-guardrails",
|
||||
"crates/mesh-llm-system",
|
||||
"crates/mesh-llm-tui",
|
||||
"crates/mesh-llm-types",
|
||||
"crates/mesh-llm-console-server",
|
||||
"crates/mesh-llm-embedded-runtime",
|
||||
"crates/mesh-llm-ui",
|
||||
"crates/mesh-llm-plugin",
|
||||
"crates/mesh-llm-skills",
|
||||
|
|
@ -60,7 +69,7 @@ version = "0.68.0"
|
|||
anyhow = "1"
|
||||
blake3 = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
mesh-llm-skills = { path = "crates/mesh-llm-skills" }
|
||||
mesh-llm-skills = { path = "crates/mesh-llm-skills", version = "0.68.0" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ let package = Package(
|
|||
dependencies: meshLLMDependencies,
|
||||
path: "sdk/swift/Sources/MeshLLM",
|
||||
exclude: hasFFIBinaryTarget ? [] : ["Generated"],
|
||||
resources: [
|
||||
.copy("Resources/Console"),
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedFramework("Accelerate"),
|
||||
.linkedFramework("AppKit", .when(platforms: [.macOS])),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ Install the latest release:
|
|||
curl -fsSL https://raw.githubusercontent.com/Mesh-LLM/mesh-llm/main/install.sh | bash
|
||||
```
|
||||
|
||||
On Windows, use PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Mesh-LLM/mesh-llm/main/install.ps1 | iex
|
||||
```
|
||||
|
||||
Join the public mesh and start serving:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
24
RELEASE.md
24
RELEASE.md
|
|
@ -112,6 +112,30 @@ cargo run -p xtask -- repo-consistency publish-crates
|
|||
scripts/publish-crates.sh --dry-run
|
||||
```
|
||||
|
||||
SDK packages that expose the optional console must package the built web
|
||||
console before publishing language SDK artifacts:
|
||||
|
||||
```bash
|
||||
scripts/package-sdk-console-assets.sh --sdk all
|
||||
scripts/verify-sdk-console-assets.sh --sdk all
|
||||
```
|
||||
|
||||
The script builds `crates/mesh-llm-ui/dist` in release mode and copies it to
|
||||
the canonical SDK resource locations: `sdk/node/console`,
|
||||
`sdk/swift/Sources/MeshLLM/Resources/Console`, and
|
||||
`sdk/kotlin/src/main/resources/mesh-llm/console`.
|
||||
|
||||
These generated directories are ignored during normal development. For a
|
||||
manual tag push, force-add them into the release commit before tagging because
|
||||
SwiftPM resolves package resources from the Git tag:
|
||||
|
||||
```bash
|
||||
git add -f sdk/node/console sdk/swift/Sources/MeshLLM/Resources/Console sdk/kotlin/src/main/resources/mesh-llm/console
|
||||
```
|
||||
|
||||
Workflow-dispatch releases generate and force-add these resources into the
|
||||
release tag commit automatically.
|
||||
|
||||
The chain currently publishes:
|
||||
|
||||
1. `model-ref`
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ categories = ["network-programming", "api-bindings"]
|
|||
name = "mesh_client"
|
||||
|
||||
[features]
|
||||
host-io = []
|
||||
host-io = ["mesh-llm-identity/host-io"]
|
||||
|
||||
[dependencies]
|
||||
# Allowlist enforced by embedded-client-purity CI (Wave 1C)
|
||||
|
|
@ -23,7 +23,7 @@ host-io = []
|
|||
# tracing, sha2, ed25519-dalek, hex, uuid, url, http, base64, async-trait, httparse
|
||||
# (httparse is a transitive dep of iroh; not in forbidden list)
|
||||
iroh = "1.0.0-rc.0"
|
||||
mesh-llm-identity = { path = "../mesh-llm-identity", version = "0.68.0" }
|
||||
mesh-llm-identity = { path = "../mesh-llm-identity", version = "0.68.0", default-features = false }
|
||||
mesh-llm-protocol = { path = "../mesh-llm-protocol", version = "0.68.0" }
|
||||
mesh-llm-routing = { path = "../mesh-llm-routing", version = "0.68.0" }
|
||||
mesh-llm-types = { path = "../mesh-llm-types", version = "0.68.0" }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
use crate::crypto::keys::OwnerKeypair;
|
||||
use crate::protocol::{ALPN_V1, STREAM_TUNNEL_HTTP};
|
||||
use crate::runtime::CoreRuntime;
|
||||
use base64::Engine;
|
||||
use iroh::{Endpoint, EndpointAddr};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
|
@ -13,8 +16,7 @@ type CancelFlagMap =
|
|||
Arc<Mutex<HashMap<String, (Arc<AtomicBool>, Arc<dyn crate::events::EventListener>)>>>;
|
||||
|
||||
pub const MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||||
const MISSING_API_BASE_URL_ERROR: &str =
|
||||
"MESH_CLIENT_API_BASE or ClientBuilder::with_api_base_url is required for inference";
|
||||
const MAX_MESH_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ClientError {
|
||||
|
|
@ -46,12 +48,19 @@ impl std::str::FromStr for InviteToken {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClientConfig {
|
||||
pub owner_keypair: OwnerKeypair,
|
||||
pub invite_token: InviteToken,
|
||||
pub user_agent: String,
|
||||
pub connect_timeout: Duration,
|
||||
pub api_base_url: Option<String>,
|
||||
pub transport: ClientTransport,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ClientTransport {
|
||||
DirectMesh,
|
||||
OpenAiHttp { api_base_url: String },
|
||||
}
|
||||
|
||||
pub struct ClientBuilder {
|
||||
|
|
@ -66,7 +75,7 @@ impl ClientBuilder {
|
|||
invite_token,
|
||||
user_agent: format!("mesh-client/{}", env!("CARGO_PKG_VERSION")),
|
||||
connect_timeout: Duration::from_secs(30),
|
||||
api_base_url: std::env::var("MESH_CLIENT_API_BASE").ok(),
|
||||
transport: default_client_transport(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -81,8 +90,19 @@ impl ClientBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_api_base_url(mut self, api_base_url: String) -> Self {
|
||||
self.config.api_base_url = Some(api_base_url);
|
||||
pub fn with_transport(mut self, transport: ClientTransport) -> Self {
|
||||
self.config.transport = transport;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_direct_mesh_transport(self) -> Self {
|
||||
self.with_transport(ClientTransport::DirectMesh)
|
||||
}
|
||||
|
||||
pub fn with_openai_http_transport(mut self, api_base_url: impl Into<String>) -> Self {
|
||||
self.config.transport = ClientTransport::OpenAiHttp {
|
||||
api_base_url: api_base_url.into(),
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -123,16 +143,9 @@ impl MeshClient {
|
|||
|
||||
/// List available models on the mesh.
|
||||
pub async fn list_models(&self) -> Result<Vec<Model>, ClientError> {
|
||||
let Some(base_url) = self.config.api_base_url.as_deref() else {
|
||||
return Err(ClientError::Endpoint(
|
||||
MISSING_API_BASE_URL_ERROR.to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let response =
|
||||
http_get_json::<ModelsResponse>(base_url, "/v1/models", &self.config.user_agent)
|
||||
.await
|
||||
.map_err(ClientError::Endpoint)?;
|
||||
let response = get_json::<ModelsResponse>(&self.config, "/v1/models")
|
||||
.await
|
||||
.map_err(ClientError::Endpoint)?;
|
||||
|
||||
Ok(response
|
||||
.data
|
||||
|
|
@ -158,57 +171,48 @@ impl MeshClient {
|
|||
.unwrap()
|
||||
.insert(id.0.clone(), (cancel_flag.clone(), listener.clone()));
|
||||
let id_clone = id.0.clone();
|
||||
let api_base_url = self.config.api_base_url.clone();
|
||||
let user_agent = self.config.user_agent.clone();
|
||||
let config = self.config.clone();
|
||||
self.runtime.handle().spawn(async move {
|
||||
if let Some(base_url) = api_base_url {
|
||||
let body = serde_json::json!({
|
||||
"model": request.model,
|
||||
"messages": request.messages.iter().map(|m| serde_json::json!({
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
})).collect::<Vec<_>>(),
|
||||
"max_tokens": 64,
|
||||
"temperature": 0,
|
||||
"stream": false,
|
||||
});
|
||||
match http_post_json::<ChatCompletionResponse>(
|
||||
&base_url,
|
||||
"/v1/chat/completions",
|
||||
&user_agent,
|
||||
body.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if !cancel_flag.load(Ordering::Relaxed) {
|
||||
if let Some(content) = response
|
||||
.choices
|
||||
.first()
|
||||
.map(|choice| choice.message.content.clone())
|
||||
{
|
||||
listener.on_event(crate::events::Event::TokenDelta {
|
||||
request_id: id_clone.clone(),
|
||||
delta: content,
|
||||
});
|
||||
}
|
||||
listener.on_event(crate::events::Event::Completed {
|
||||
let body = serde_json::json!({
|
||||
"model": request.model,
|
||||
"messages": request.messages.iter().map(|m| serde_json::json!({
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
})).collect::<Vec<_>>(),
|
||||
"max_tokens": 64,
|
||||
"temperature": 0,
|
||||
"stream": false,
|
||||
});
|
||||
match post_json::<ChatCompletionResponse>(
|
||||
&config,
|
||||
"/v1/chat/completions",
|
||||
body.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if !cancel_flag.load(Ordering::Relaxed) {
|
||||
if let Some(content) = response
|
||||
.choices
|
||||
.first()
|
||||
.map(|choice| choice.message.content.clone())
|
||||
{
|
||||
listener.on_event(crate::events::Event::TokenDelta {
|
||||
request_id: id_clone.clone(),
|
||||
delta: content,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
listener.on_event(crate::events::Event::Failed {
|
||||
listener.on_event(crate::events::Event::Completed {
|
||||
request_id: id_clone.clone(),
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if !cancel_flag.load(Ordering::Relaxed) {
|
||||
listener.on_event(crate::events::Event::Failed {
|
||||
request_id: id_clone,
|
||||
error: MISSING_API_BASE_URL_ERROR.to_string(),
|
||||
});
|
||||
Err(error) => {
|
||||
listener.on_event(crate::events::Event::Failed {
|
||||
request_id: id_clone,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
id
|
||||
|
|
@ -227,57 +231,48 @@ impl MeshClient {
|
|||
.unwrap()
|
||||
.insert(id.0.clone(), (cancel_flag.clone(), listener.clone()));
|
||||
let id_clone = id.0.clone();
|
||||
let api_base_url = self.config.api_base_url.clone();
|
||||
let user_agent = self.config.user_agent.clone();
|
||||
let config = self.config.clone();
|
||||
self.runtime.handle().spawn(async move {
|
||||
if let Some(base_url) = api_base_url {
|
||||
let body = serde_json::json!({
|
||||
"model": request.model,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": request.input,
|
||||
}],
|
||||
"max_tokens": 64,
|
||||
"temperature": 0,
|
||||
"stream": false,
|
||||
});
|
||||
match http_post_json::<ChatCompletionResponse>(
|
||||
&base_url,
|
||||
"/v1/chat/completions",
|
||||
&user_agent,
|
||||
body.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if !cancel_flag.load(Ordering::Relaxed) {
|
||||
if let Some(content) = response
|
||||
.choices
|
||||
.first()
|
||||
.map(|choice| choice.message.content.clone())
|
||||
{
|
||||
listener.on_event(crate::events::Event::TokenDelta {
|
||||
request_id: id_clone.clone(),
|
||||
delta: content,
|
||||
});
|
||||
}
|
||||
listener.on_event(crate::events::Event::Completed {
|
||||
let body = serde_json::json!({
|
||||
"model": request.model,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": request.input,
|
||||
}],
|
||||
"max_tokens": 64,
|
||||
"temperature": 0,
|
||||
"stream": false,
|
||||
});
|
||||
match post_json::<ChatCompletionResponse>(
|
||||
&config,
|
||||
"/v1/chat/completions",
|
||||
body.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if !cancel_flag.load(Ordering::Relaxed) {
|
||||
if let Some(content) = response
|
||||
.choices
|
||||
.first()
|
||||
.map(|choice| choice.message.content.clone())
|
||||
{
|
||||
listener.on_event(crate::events::Event::TokenDelta {
|
||||
request_id: id_clone.clone(),
|
||||
delta: content,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
listener.on_event(crate::events::Event::Failed {
|
||||
listener.on_event(crate::events::Event::Completed {
|
||||
request_id: id_clone.clone(),
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if !cancel_flag.load(Ordering::Relaxed) {
|
||||
listener.on_event(crate::events::Event::Failed {
|
||||
request_id: id_clone,
|
||||
error: MISSING_API_BASE_URL_ERROR.to_string(),
|
||||
});
|
||||
Err(error) => {
|
||||
listener.on_event(crate::events::Event::Failed {
|
||||
request_id: id_clone,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
id
|
||||
|
|
@ -389,6 +384,14 @@ impl Default for RequestId {
|
|||
}
|
||||
}
|
||||
|
||||
fn default_client_transport() -> ClientTransport {
|
||||
std::env::var("MESH_CLIENT_API_BASE")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|api_base_url| ClientTransport::OpenAiHttp { api_base_url })
|
||||
.unwrap_or(ClientTransport::DirectMesh)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ModelsResponse {
|
||||
data: Vec<ModelEntry>,
|
||||
|
|
@ -414,33 +417,162 @@ struct ChatMessageResponse {
|
|||
content: String,
|
||||
}
|
||||
|
||||
async fn http_get_json<T: for<'de> Deserialize<'de>>(
|
||||
base_url: &str,
|
||||
async fn get_json<T: for<'de> Deserialize<'de>>(
|
||||
config: &ClientConfig,
|
||||
path: &str,
|
||||
user_agent: &str,
|
||||
) -> Result<T, String> {
|
||||
let request = format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {}\r\nUser-Agent: {user_agent}\r\nConnection: close\r\n\r\n",
|
||||
host_header(base_url)?
|
||||
);
|
||||
let response = http_request(base_url, request).await?;
|
||||
let response = request_get_bytes(config, path).await?;
|
||||
parse_json_response(&response)
|
||||
}
|
||||
|
||||
async fn http_post_json<T: for<'de> Deserialize<'de>>(
|
||||
base_url: &str,
|
||||
async fn post_json<T: for<'de> Deserialize<'de>>(
|
||||
config: &ClientConfig,
|
||||
path: &str,
|
||||
user_agent: &str,
|
||||
body: String,
|
||||
) -> Result<T, String> {
|
||||
let request = format!(
|
||||
"POST {path} HTTP/1.1\r\nHost: {}\r\nUser-Agent: {user_agent}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
host_header(base_url)?,
|
||||
let response = request_post_bytes(config, path, body).await?;
|
||||
parse_json_response(&response)
|
||||
}
|
||||
|
||||
async fn request_get_bytes(config: &ClientConfig, path: &str) -> Result<Vec<u8>, String> {
|
||||
match &config.transport {
|
||||
ClientTransport::DirectMesh => {
|
||||
let request = http_get_request(path, "mesh.local", &config.user_agent);
|
||||
direct_mesh_request(&config.invite_token, config.connect_timeout, request).await
|
||||
}
|
||||
ClientTransport::OpenAiHttp { api_base_url } => {
|
||||
let request = http_get_request(path, &host_header(api_base_url)?, &config.user_agent);
|
||||
http_request(api_base_url, request).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_post_bytes(
|
||||
config: &ClientConfig,
|
||||
path: &str,
|
||||
body: String,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
match &config.transport {
|
||||
ClientTransport::DirectMesh => {
|
||||
let request = http_post_request(path, "mesh.local", &config.user_agent, body);
|
||||
direct_mesh_request(&config.invite_token, config.connect_timeout, request).await
|
||||
}
|
||||
ClientTransport::OpenAiHttp { api_base_url } => {
|
||||
let request =
|
||||
http_post_request(path, &host_header(api_base_url)?, &config.user_agent, body);
|
||||
http_request(api_base_url, request).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn http_get_request(path: &str, host: &str, user_agent: &str) -> String {
|
||||
format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: {user_agent}\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
}
|
||||
|
||||
fn http_post_request(path: &str, host: &str, user_agent: &str, body: String) -> String {
|
||||
format!(
|
||||
"POST {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: {user_agent}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let response = http_request(base_url, request).await?;
|
||||
parse_json_response(&response)
|
||||
)
|
||||
}
|
||||
|
||||
async fn direct_mesh_request(
|
||||
invite_token: &InviteToken,
|
||||
connect_timeout: Duration,
|
||||
request: String,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let addr = decode_invite_endpoint_addr(invite_token.as_str())?;
|
||||
let mut builder = Endpoint::builder(iroh::endpoint::presets::Minimal)
|
||||
.secret_key(iroh::SecretKey::generate())
|
||||
.alpns(vec![ALPN_V1.to_vec()])
|
||||
.bind_addr(std::net::SocketAddr::from(([0, 0, 0, 0], 0)))
|
||||
.map_err(|err| format!("build mesh endpoint: {err}"))?;
|
||||
builder = builder.relay_mode(relay_mode_from_endpoint_addr(&addr));
|
||||
let endpoint = builder
|
||||
.bind()
|
||||
.await
|
||||
.map_err(|err| format!("bind mesh endpoint: {err}"))?;
|
||||
let result = direct_mesh_request_with_endpoint(&endpoint, addr, connect_timeout, request).await;
|
||||
endpoint.close().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn direct_mesh_request_with_endpoint(
|
||||
endpoint: &Endpoint,
|
||||
addr: EndpointAddr,
|
||||
connect_timeout: Duration,
|
||||
request: String,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if addr.relay_urls().next().is_some() {
|
||||
let _ = tokio::time::timeout(connect_timeout, endpoint.online()).await;
|
||||
}
|
||||
let connection = tokio::time::timeout(connect_timeout, endpoint.connect(addr, ALPN_V1))
|
||||
.await
|
||||
.map_err(|_| "connect mesh endpoint: timed out".to_string())?
|
||||
.map_err(|err| format!("connect mesh endpoint: {err}"))?;
|
||||
let (mut send, mut recv) = connection
|
||||
.open_bi()
|
||||
.await
|
||||
.map_err(|err| format!("open mesh request stream: {err}"))?;
|
||||
send.write_all(&[STREAM_TUNNEL_HTTP])
|
||||
.await
|
||||
.map_err(|err| format!("write mesh request stream type: {err}"))?;
|
||||
send.write_all(request.as_bytes())
|
||||
.await
|
||||
.map_err(|err| format!("write mesh request: {err}"))?;
|
||||
send.finish()
|
||||
.map_err(|err| format!("finish mesh request: {err}"))?;
|
||||
|
||||
let response = recv
|
||||
.read_to_end(MAX_MESH_RESPONSE_BYTES)
|
||||
.await
|
||||
.map_err(|err| format!("read mesh response: {err}"))?;
|
||||
connection.close(0u32.into(), b"mesh-client-request-complete");
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SignedBootstrapTokenAddrs {
|
||||
serialized_addrs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn decode_invite_endpoint_addr(invite_token: &str) -> Result<EndpointAddr, String> {
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(invite_token)
|
||||
.map_err(|err| format!("invalid invite token encoding: {err}"))?;
|
||||
if let Ok(addr) = serde_json::from_slice::<EndpointAddr>(&payload) {
|
||||
return Ok(addr);
|
||||
}
|
||||
let signed = serde_json::from_slice::<SignedBootstrapTokenAddrs>(&payload)
|
||||
.map_err(|err| format!("invalid invite token payload: {err}"))?;
|
||||
let addr = signed
|
||||
.serialized_addrs
|
||||
.first()
|
||||
.ok_or_else(|| "signed invite token has no endpoint addresses".to_string())?;
|
||||
serde_json::from_slice(addr).map_err(|err| format!("invalid signed invite endpoint: {err}"))
|
||||
}
|
||||
|
||||
fn relay_mode_from_endpoint_addr(addr: &EndpointAddr) -> iroh::endpoint::RelayMode {
|
||||
match relay_map_from_endpoint_addr(addr) {
|
||||
Some(relay_map) => iroh::endpoint::RelayMode::Custom(relay_map),
|
||||
None => iroh::endpoint::RelayMode::Disabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn relay_map_from_endpoint_addr(addr: &EndpointAddr) -> Option<iroh::RelayMap> {
|
||||
let configs: Vec<_> = addr
|
||||
.relay_urls()
|
||||
.cloned()
|
||||
.map(|url| iroh::RelayConfig::new(url, None))
|
||||
.collect();
|
||||
if configs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(iroh::RelayMap::from_iter(configs))
|
||||
}
|
||||
}
|
||||
|
||||
async fn http_request(base_url: &str, request: String) -> Result<Vec<u8>, String> {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
pub mod builder;
|
||||
pub mod control_plane;
|
||||
pub use builder::{
|
||||
ChatMessage, ChatRequest, ClientBuilder, ClientConfig, ClientError, InviteToken, MeshClient,
|
||||
Model, RequestId, ResponsesRequest, Status,
|
||||
ChatMessage, ChatRequest, ClientBuilder, ClientConfig, ClientError, ClientTransport,
|
||||
InviteToken, MeshClient, Model, RequestId, ResponsesRequest, Status,
|
||||
};
|
||||
pub use control_plane::{
|
||||
ConfigTransportSelection, ControlPlaneBootstrapOptions, ControlPlaneClientError,
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ pub mod events;
|
|||
pub mod protocol;
|
||||
|
||||
pub use client::{
|
||||
ChatMessage, ChatRequest, ClientBuilder, ClientError, ConfigTransportSelection,
|
||||
ControlPlaneBootstrapOptions, ControlPlaneClientError, ControlPlaneConnection,
|
||||
ControlPlaneNegotiationError, ControlPlaneRetryPolicy, InviteToken, MeshClient, Model,
|
||||
OwnerControlClient, OwnerControlRemoteError, OwnerControlWatchEvent, OwnerControlWatchStream,
|
||||
RequestId, ResponsesRequest, Status,
|
||||
ChatMessage, ChatRequest, ClientBuilder, ClientError, ClientTransport,
|
||||
ConfigTransportSelection, ControlPlaneBootstrapOptions, ControlPlaneClientError,
|
||||
ControlPlaneConnection, ControlPlaneNegotiationError, ControlPlaneRetryPolicy, InviteToken,
|
||||
MeshClient, Model, OwnerControlClient, OwnerControlRemoteError, OwnerControlWatchEvent,
|
||||
OwnerControlWatchStream, RequestId, ResponsesRequest, Status,
|
||||
};
|
||||
pub use crypto::keys::OwnerKeypair;
|
||||
|
|
|
|||
|
|
@ -67,13 +67,13 @@ async fn mesh_client_cancel_idempotent() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mesh_client_list_models_requires_api_base_url() {
|
||||
async fn mesh_client_list_models_defaults_to_direct_mesh_transport() {
|
||||
let kp = OwnerKeypair::generate();
|
||||
let token = InviteToken::from_str("test-token").unwrap();
|
||||
let client = ClientBuilder::new(kp, token).build().unwrap();
|
||||
|
||||
let err = client.list_models().await.unwrap_err();
|
||||
assert!(err.to_string().contains("MESH_CLIENT_API_BASE"));
|
||||
assert!(err.to_string().contains("invalid invite token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use std::time::Duration;
|
|||
use thiserror::Error;
|
||||
|
||||
pub const MAX_RECONNECT_ATTEMPTS: u32 = mesh_client::client::builder::MAX_RECONNECT_ATTEMPTS;
|
||||
pub type ClientTransport = mesh_client::ClientTransport;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MeshApiError {
|
||||
|
|
@ -33,6 +34,7 @@ pub struct ClientConfig {
|
|||
pub invite_token: InviteToken,
|
||||
pub user_agent: String,
|
||||
pub connect_timeout: Duration,
|
||||
pub transport: ClientTransport,
|
||||
}
|
||||
|
||||
pub struct ClientBuilder {
|
||||
|
|
@ -47,6 +49,7 @@ impl ClientBuilder {
|
|||
invite_token,
|
||||
user_agent: format!("mesh-llm-api-client/{}", env!("CARGO_PKG_VERSION")),
|
||||
connect_timeout: Duration::from_secs(30),
|
||||
transport: ClientTransport::DirectMesh,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -61,14 +64,33 @@ impl ClientBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_transport(mut self, transport: ClientTransport) -> Self {
|
||||
self.config.transport = transport;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_direct_mesh_transport(self) -> Self {
|
||||
self.with_transport(ClientTransport::DirectMesh)
|
||||
}
|
||||
|
||||
pub fn with_openai_http_transport(mut self, api_base_url: impl Into<String>) -> Self {
|
||||
self.config.transport = ClientTransport::OpenAiHttp {
|
||||
api_base_url: api_base_url.into(),
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<MeshClient, MeshApiError> {
|
||||
let inner = mesh_client::ClientBuilder::new(
|
||||
let mut builder = mesh_client::ClientBuilder::new(
|
||||
self.config.owner_keypair.into_inner(),
|
||||
self.config.invite_token.into_inner(),
|
||||
)
|
||||
.with_user_agent(self.config.user_agent.clone())
|
||||
.with_connect_timeout(self.config.connect_timeout)
|
||||
.build()?;
|
||||
.with_connect_timeout(self.config.connect_timeout);
|
||||
|
||||
builder = builder.with_transport(self.config.transport);
|
||||
|
||||
let inner = builder.build()?;
|
||||
|
||||
Ok(MeshClient { inner })
|
||||
}
|
||||
|
|
@ -252,3 +274,33 @@ impl mesh_client::events::EventListener for EventListenerAdapter {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builder_accepts_explicit_openai_http_transport() {
|
||||
let owner = OwnerKeypair::generate();
|
||||
let invite = "mesh-test:token".parse::<InviteToken>().unwrap();
|
||||
|
||||
let builder = ClientBuilder::new(owner, invite)
|
||||
.with_openai_http_transport("http://127.0.0.1:9337/v1");
|
||||
|
||||
assert_eq!(
|
||||
builder.config.transport,
|
||||
ClientTransport::OpenAiHttp {
|
||||
api_base_url: "http://127.0.0.1:9337/v1".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_defaults_to_direct_mesh_transport() {
|
||||
let owner = OwnerKeypair::generate();
|
||||
let invite = "mesh-test:token".parse::<InviteToken>().unwrap();
|
||||
let builder = ClientBuilder::new(owner, invite);
|
||||
|
||||
assert_eq!(builder.config.transport, ClientTransport::DirectMesh);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ mod identity;
|
|||
mod token;
|
||||
|
||||
pub use client::{
|
||||
ChatMessage, ChatRequest, ClientBuilder, ClientConfig, MAX_RECONNECT_ATTEMPTS, MeshApiError,
|
||||
MeshClient, Model, RequestId, ResponsesRequest, Status,
|
||||
ChatMessage, ChatRequest, ClientBuilder, ClientConfig, ClientTransport, MAX_RECONNECT_ATTEMPTS,
|
||||
MeshApiError, MeshClient, Model, RequestId, ResponsesRequest, Status,
|
||||
};
|
||||
pub use discover::{
|
||||
AutoConnectResult, PublicMesh, PublicMeshQuery, create_auto_client, discover_public_meshes,
|
||||
|
|
|
|||
20
crates/mesh-llm-cli/Cargo.toml
Normal file
20
crates/mesh-llm-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "mesh-llm-cli"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
version.workspace = true
|
||||
description = "Reusable CLI support surface for mesh-llm"
|
||||
repository = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
homepage = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
readme = "README.md"
|
||||
keywords = ["llm", "mesh", "cli"]
|
||||
categories = ["command-line-interface"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
mesh-llm-events = { path = "../mesh-llm-events", version = "0.68.0" }
|
||||
serde.workspace = true
|
||||
9
crates/mesh-llm-cli/README.md
Normal file
9
crates/mesh-llm-cli/README.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# mesh-llm-cli
|
||||
|
||||
`mesh-llm-cli` owns the command-line surface for the shipped `mesh-llm` binary:
|
||||
Clap parser types, runtime surface normalization, terminal progress indicators,
|
||||
pager behavior, shell quoting, and shared CLI-facing output format types.
|
||||
|
||||
The current host runtime still owns command dispatch while its handlers are
|
||||
being untangled from runtime internals. New parser types and CLI-only helpers
|
||||
should live here instead of in `mesh-llm-host-runtime`.
|
||||
|
|
@ -2,7 +2,7 @@ use clap::{Subcommand, ValueEnum};
|
|||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum BenchmarkCommand {
|
||||
pub enum BenchmarkCommand {
|
||||
/// Import a prompt corpus from a supported online source into local JSONL.
|
||||
#[command(name = "import-prompts")]
|
||||
ImportPrompts {
|
||||
|
|
@ -27,7 +27,7 @@ pub(crate) enum BenchmarkCommand {
|
|||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
||||
pub(crate) enum GpuBenchmarkBackend {
|
||||
pub enum GpuBenchmarkBackend {
|
||||
Metal,
|
||||
Cuda,
|
||||
Hip,
|
||||
|
|
@ -35,7 +35,7 @@ pub(crate) enum GpuBenchmarkBackend {
|
|||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
||||
pub(crate) enum PromptImportSource {
|
||||
pub enum PromptImportSource {
|
||||
MtBench,
|
||||
Gsm8k,
|
||||
Humaneval,
|
||||
17
crates/mesh-llm-cli/src/lib.rs
Normal file
17
crates/mesh-llm-cli/src/lib.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod benchmark;
|
||||
pub mod models;
|
||||
pub mod pager;
|
||||
pub mod parser;
|
||||
pub mod runtime;
|
||||
pub mod shell;
|
||||
|
||||
pub use mesh_llm_events::LogFormat;
|
||||
|
||||
pub use parser::{
|
||||
AuthCommand, BinaryFlavor, Cli, Command, DiscoveryScope, DoctorCommand, GpuCommand,
|
||||
MeshDiscoveryMode, MeshGuardrailCliMode, NormalizedRuntimeArgs, PluginCommand, RuntimeSurface,
|
||||
SkillAgentArg, SkillCommand, TrustCommand, TrustPolicy, legacy_runtime_surface_warning,
|
||||
normalize_runtime_surface_args, validate_discovery_mode_args,
|
||||
};
|
||||
|
|
@ -6,7 +6,7 @@ use std::process::{Command, Stdio};
|
|||
const DEFAULT_PAGER: &str = "less";
|
||||
const DEFAULT_PAGER_ARGS: &[&str] = &["-F", "-R", "-X"];
|
||||
|
||||
pub(crate) fn print_or_page(output: &str) -> Result<()> {
|
||||
pub fn print_or_page(output: &str) -> Result<()> {
|
||||
if !should_use_pager(
|
||||
std::io::stdin().is_terminal(),
|
||||
std::io::stdout().is_terminal(),
|
||||
|
|
@ -3,10 +3,76 @@ use std::ffi::OsString;
|
|||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::cli::benchmark::BenchmarkCommand;
|
||||
use crate::cli::runtime::RuntimeCommand;
|
||||
use crate::crypto::TrustPolicy;
|
||||
use crate::network::discovery::MeshDiscoveryMode;
|
||||
use crate::benchmark::BenchmarkCommand;
|
||||
use crate::models;
|
||||
use crate::runtime::RuntimeCommand;
|
||||
use mesh_llm_events::LogFormat;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
|
||||
pub enum BinaryFlavor {
|
||||
#[default]
|
||||
Cpu,
|
||||
Cuda,
|
||||
Rocm,
|
||||
Vulkan,
|
||||
Metal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
|
||||
pub enum TrustPolicy {
|
||||
#[default]
|
||||
Off,
|
||||
PreferOwned,
|
||||
RequireOwned,
|
||||
Allowlist,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
|
||||
pub enum MeshDiscoveryMode {
|
||||
#[default]
|
||||
Nostr,
|
||||
Mdns,
|
||||
}
|
||||
|
||||
impl MeshDiscoveryMode {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Nostr => "nostr",
|
||||
Self::Mdns => "mdns",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn source(self) -> &'static str {
|
||||
match self {
|
||||
Self::Nostr => "nostr-relay",
|
||||
Self::Mdns => "mdns-sd",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn scope(self) -> DiscoveryScope {
|
||||
match self {
|
||||
Self::Nostr => DiscoveryScope::Public,
|
||||
Self::Mdns => DiscoveryScope::Lan,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DiscoveryScope {
|
||||
Public,
|
||||
Lan,
|
||||
}
|
||||
|
||||
impl DiscoveryScope {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Public => "public",
|
||||
Self::Lan => "lan",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `URL=TOKEN` pair for `--relay-auth`. Splits on the first `=` only,
|
||||
/// so tokens may contain `=` (base64 padding, JWTs).
|
||||
|
|
@ -100,7 +166,7 @@ mod relay_auth_parser_tests {
|
|||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum TrustCommand {
|
||||
pub enum TrustCommand {
|
||||
/// Add an owner to the local trust store allowlist.
|
||||
Add {
|
||||
/// Owner ID to trust.
|
||||
|
|
@ -129,7 +195,7 @@ pub(crate) enum TrustCommand {
|
|||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum AuthCommand {
|
||||
pub enum AuthCommand {
|
||||
/// Generate a new owner keypair and save to keystore.
|
||||
Init {
|
||||
/// Path to the owner keystore.
|
||||
|
|
@ -296,7 +362,7 @@ pub(crate) enum AuthCommand {
|
|||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum GpuCommand {
|
||||
pub enum GpuCommand {
|
||||
/// Detect and benchmark local GPUs, rewriting the cached fingerprint.
|
||||
Detect {
|
||||
/// Print machine-readable JSON output.
|
||||
|
|
@ -305,24 +371,8 @@ pub(crate) enum GpuCommand {
|
|||
},
|
||||
}
|
||||
|
||||
pub(crate) mod benchmark;
|
||||
pub(crate) mod commands;
|
||||
pub mod models;
|
||||
pub mod output;
|
||||
pub(crate) mod pager;
|
||||
pub(crate) mod runtime;
|
||||
pub(crate) mod shell;
|
||||
pub(crate) mod terminal_progress;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
|
||||
pub enum LogFormat {
|
||||
#[default]
|
||||
Pretty,
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
|
||||
pub(crate) enum MeshGuardrailCliMode {
|
||||
pub enum MeshGuardrailCliMode {
|
||||
#[default]
|
||||
Disabled,
|
||||
Metrics,
|
||||
|
|
@ -330,200 +380,192 @@ pub(crate) enum MeshGuardrailCliMode {
|
|||
}
|
||||
|
||||
impl MeshGuardrailCliMode {
|
||||
pub(crate) const fn as_str(self) -> &'static str {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Disabled => "disabled",
|
||||
Self::Metrics => "metrics",
|
||||
Self::Enforce => "enforce",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn to_guardrail_mode(self) -> openai_frontend::GuardrailMode {
|
||||
match self {
|
||||
Self::Disabled => openai_frontend::GuardrailMode::Disabled,
|
||||
Self::Metrics => openai_frontend::GuardrailMode::MetricsOnly,
|
||||
Self::Enforce => openai_frontend::GuardrailMode::Enforce,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "mesh-llm",
|
||||
version = crate::VERSION,
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
about = "Pool GPUs over the internet for LLM inference",
|
||||
after_help = "Preferred runtime entrypoints:\n mesh-llm serve\n mesh-llm serve --model Qwen3-8B-Q4_K_M\n mesh-llm client --auto\n mesh-llm gpus\n\n`mesh-llm serve` loads startup models from ~/.mesh-llm/config.toml.\nRun with --help-advanced for all options.\n\nExternal backends (vLLM, TGI, Ollama):\n Install the plugin:\n mesh-llm plugins install openai-endpoint\n Add to ~/.mesh-llm/config.toml:\n [[plugin]]\n name = \"openai-endpoint\"\n url = \"http://gpu-box:8000/v1\"\n Then: mesh-llm serve (or: mesh-llm client for client-only mode)\n\nFlash-MoE SSD backend:\n Install the plugin:\n mesh-llm plugins install flash-moe\n Add [[plugin]] name = \"flash-moe\" with url or plugin-owned args.\n Then: mesh-llm serve (or: mesh-llm client for client-only mode)"
|
||||
)]
|
||||
pub(crate) struct Cli {
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Option<Command>,
|
||||
pub command: Option<Command>,
|
||||
|
||||
/// Terminal output format for app-owned runtime events.
|
||||
#[arg(long, value_enum, default_value_t = LogFormat::Pretty)]
|
||||
pub(crate) log_format: LogFormat,
|
||||
pub log_format: LogFormat,
|
||||
|
||||
/// Enable mesh runtime debug output; set MESH_LLM_DEBUG_NATIVE_VERBOSE=1 for verbose llama.cpp native logs.
|
||||
#[arg(long)]
|
||||
pub(crate) debug: bool,
|
||||
pub debug: bool,
|
||||
|
||||
/// OTLP/gRPC endpoint for embedded Skippy debug telemetry, for example http://127.0.0.1:14317.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) skippy_metrics_otlp_grpc: Option<String>,
|
||||
pub skippy_metrics_otlp_grpc: Option<String>,
|
||||
|
||||
/// Server-side mesh guardrail mode for hosted Skippy backends.
|
||||
#[arg(long = "mesh-guardrails", value_enum, default_value_t = MeshGuardrailCliMode::Disabled)]
|
||||
pub(crate) mesh_guardrails: MeshGuardrailCliMode,
|
||||
pub mesh_guardrails: MeshGuardrailCliMode,
|
||||
|
||||
/// Show all options (including advanced/niche ones).
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) help_advanced: bool,
|
||||
pub help_advanced: bool,
|
||||
|
||||
/// Join a mesh via invite token (can repeat).
|
||||
#[arg(long, short)]
|
||||
pub(crate) join: Vec<String>,
|
||||
pub join: Vec<String>,
|
||||
|
||||
/// Discover a mesh and join it.
|
||||
#[arg(long, default_missing_value = "", num_args = 0..=1)]
|
||||
pub(crate) discover: Option<String>,
|
||||
pub discover: Option<String>,
|
||||
|
||||
/// Auto-join the best mesh found via discovery.
|
||||
#[arg(long)]
|
||||
pub(crate) auto: bool,
|
||||
pub auto: bool,
|
||||
|
||||
/// Discovery provider for --auto, --discover, --publish, and the discover command.
|
||||
#[arg(long, value_enum, default_value_t = MeshDiscoveryMode::Nostr, global = true)]
|
||||
pub(crate) mesh_discovery_mode: MeshDiscoveryMode,
|
||||
pub mesh_discovery_mode: MeshDiscoveryMode,
|
||||
|
||||
/// Model to serve (path, remote catalog name, or Hugging Face ref).
|
||||
#[arg(long)]
|
||||
pub(crate) model: Vec<PathBuf>,
|
||||
pub model: Vec<PathBuf>,
|
||||
|
||||
/// Raw local GGUF file to serve directly (repeatable).
|
||||
#[arg(long)]
|
||||
pub(crate) gguf: Vec<PathBuf>,
|
||||
pub gguf: Vec<PathBuf>,
|
||||
|
||||
/// Explicit mmproj sidecar for the primary served model.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) mmproj: Option<PathBuf>,
|
||||
pub mmproj: Option<PathBuf>,
|
||||
|
||||
/// API port (default: 9337).
|
||||
#[arg(long, default_value = "9337")]
|
||||
pub(crate) port: u16,
|
||||
pub port: u16,
|
||||
|
||||
/// Run as a client — no GPU, no model needed.
|
||||
#[arg(long)]
|
||||
pub(crate) client: bool,
|
||||
pub client: bool,
|
||||
|
||||
/// Web console port (default: 3131).
|
||||
#[arg(long, default_value = "3131")]
|
||||
pub(crate) console: u16,
|
||||
pub console: u16,
|
||||
|
||||
/// Disable the embedded web UI but keep the management API on the --console port.
|
||||
#[arg(long)]
|
||||
pub(crate) headless: bool,
|
||||
pub headless: bool,
|
||||
|
||||
/// Write passive swarm debug capture JSONL to this local directory (opt-in, no telemetry egress).
|
||||
#[arg(long)]
|
||||
pub(crate) swarm_capture: Option<PathBuf>,
|
||||
pub swarm_capture: Option<PathBuf>,
|
||||
|
||||
/// Publish this mesh for discovery by other nodes.
|
||||
/// Without this flag, your mesh is private and only joinable via invite token.
|
||||
#[arg(long)]
|
||||
pub(crate) publish: bool,
|
||||
pub publish: bool,
|
||||
|
||||
/// Human-readable name for this mesh (shown in discovery when combined with --publish).
|
||||
/// Naming a mesh does NOT make it publicly discoverable — use --publish for that.
|
||||
#[arg(long)]
|
||||
pub(crate) mesh_name: Option<String>,
|
||||
pub mesh_name: Option<String>,
|
||||
|
||||
/// Region tag, e.g. "US", "EU", "AU" (shown in discovery).
|
||||
#[arg(long)]
|
||||
pub(crate) region: Option<String>,
|
||||
pub region: Option<String>,
|
||||
|
||||
/// Minimum mesh-llm node version required when creating a new mesh.
|
||||
#[arg(long)]
|
||||
pub(crate) min_node_version: Option<String>,
|
||||
pub min_node_version: Option<String>,
|
||||
|
||||
/// Maximum mesh-llm node version allowed when creating a new mesh.
|
||||
#[arg(long)]
|
||||
pub(crate) max_node_version: Option<String>,
|
||||
pub max_node_version: Option<String>,
|
||||
|
||||
/// Minimum protocol generation required when creating a new mesh.
|
||||
#[arg(long)]
|
||||
pub(crate) min_protocol_version: Option<u32>,
|
||||
pub min_protocol_version: Option<u32>,
|
||||
|
||||
/// Maximum protocol generation allowed when creating a new mesh.
|
||||
#[arg(long)]
|
||||
pub(crate) max_protocol_version: Option<u32>,
|
||||
pub max_protocol_version: Option<u32>,
|
||||
|
||||
/// Require release attestation when creating a new mesh.
|
||||
#[arg(long)]
|
||||
pub(crate) require_release_attestation: bool,
|
||||
pub require_release_attestation: bool,
|
||||
|
||||
/// Allowed release signer key for mesh creation-time attestation policy (repeatable).
|
||||
#[arg(long = "release-signer-key")]
|
||||
pub(crate) release_signer_key: Vec<String>,
|
||||
pub release_signer_key: Vec<String>,
|
||||
|
||||
/// Display name for this node.
|
||||
#[arg(long)]
|
||||
pub(crate) name: Option<String>,
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Internal plugin service mode.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) plugin: Option<String>,
|
||||
pub plugin: Option<String>,
|
||||
|
||||
/// Update mesh-llm before continuing for release-bundle installs if a newer bundled release is available.
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) auto_update: bool,
|
||||
pub auto_update: bool,
|
||||
|
||||
// ── Advanced options (hidden from default --help) ─────────────
|
||||
/// Draft model for speculative decoding.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) draft: Option<PathBuf>,
|
||||
pub draft: Option<PathBuf>,
|
||||
|
||||
/// Max draft tokens (default: 8).
|
||||
#[arg(long, default_value = "8", hide = true)]
|
||||
pub(crate) draft_max: u16,
|
||||
pub draft_max: u16,
|
||||
|
||||
/// Disable automatic draft model detection.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) no_draft: bool,
|
||||
pub no_draft: bool,
|
||||
|
||||
/// Force tensor split even if the model fits on one node.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) split: bool,
|
||||
pub split: bool,
|
||||
|
||||
/// Override context size (tokens). Default: auto-scaled to available VRAM.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) ctx_size: Option<u32>,
|
||||
pub ctx_size: Option<u32>,
|
||||
|
||||
/// Cap VRAM used for planning, local-fit decisions, and mesh advertisement (GB).
|
||||
#[arg(long)]
|
||||
pub(crate) max_vram: Option<f64>,
|
||||
pub max_vram: Option<f64>,
|
||||
|
||||
/// Disable broadcasting GPU name, hostname, VRAM, and reserved bytes to peers. By default all nodes announce this hardware info.
|
||||
#[arg(long = "no-enumerate-host", hide = true)]
|
||||
pub(crate) no_enumerate_host: bool,
|
||||
pub no_enumerate_host: bool,
|
||||
|
||||
/// Path to bundled mesh support binaries.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) bin_dir: Option<PathBuf>,
|
||||
pub bin_dir: Option<PathBuf>,
|
||||
|
||||
/// Override which bundled llama.cpp flavor to use.
|
||||
#[arg(long, value_enum)]
|
||||
pub(crate) llama_flavor: Option<crate::system::backend::BinaryFlavor>,
|
||||
pub llama_flavor: Option<BinaryFlavor>,
|
||||
|
||||
/// Device override for local backend selection.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) device: Option<String>,
|
||||
pub device: Option<String>,
|
||||
|
||||
/// Deprecated tensor split override retained for CLI compatibility.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) tensor_split: Option<String>,
|
||||
pub tensor_split: Option<String>,
|
||||
|
||||
/// Override iroh relay URLs.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) relay: Vec<String>,
|
||||
pub relay: Vec<String>,
|
||||
|
||||
/// Per-relay bearer token for gated iroh relays, formatted as
|
||||
/// `URL=TOKEN`. Repeatable. The token is sent as
|
||||
|
|
@ -534,70 +576,74 @@ pub(crate) struct Cli {
|
|||
/// Splits on the first `=` only, so tokens may contain `=` (base64
|
||||
/// padding, JWTs, etc.).
|
||||
#[arg(long = "relay-auth", value_parser = parse_relay_auth_pair, hide = true)]
|
||||
pub(crate) relay_auth: Vec<(String, String)>,
|
||||
pub relay_auth: Vec<(String, String)>,
|
||||
|
||||
/// Disable iroh relays even when public mesh discovery would normally use them.
|
||||
#[arg(long = "disable-iroh-relays", hide = true)]
|
||||
pub disable_iroh_relays: bool,
|
||||
|
||||
/// Bind QUIC to a fixed UDP port (for NAT port forwarding).
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) bind_port: Option<u16>,
|
||||
pub bind_port: Option<u16>,
|
||||
|
||||
/// Bind mesh QUIC to a specific local IP address.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) bind_ip: Option<IpAddr>,
|
||||
pub bind_ip: Option<IpAddr>,
|
||||
|
||||
/// Bind to 0.0.0.0 (for containers/Fly.io).
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) listen_all: bool,
|
||||
pub listen_all: bool,
|
||||
|
||||
/// Stop advertising when N clients connected.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) max_clients: Option<usize>,
|
||||
pub max_clients: Option<usize>,
|
||||
|
||||
/// Custom Nostr relay URLs.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) nostr_relay: Vec<String>,
|
||||
pub nostr_relay: Vec<String>,
|
||||
|
||||
/// Ignored (backward compat).
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) no_console: bool,
|
||||
pub no_console: bool,
|
||||
|
||||
/// Optional path to the mesh-llm config file.
|
||||
#[arg(long)]
|
||||
pub(crate) config: Option<PathBuf>,
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
/// Path to the owner keystore used to attest this node.
|
||||
#[arg(long)]
|
||||
pub(crate) owner_key: Option<PathBuf>,
|
||||
pub owner_key: Option<PathBuf>,
|
||||
|
||||
/// Bind address for the owner-control listener. Defaults to 127.0.0.1:0 when owner identity is configured.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) control_bind: Option<std::net::SocketAddr>,
|
||||
pub control_bind: Option<std::net::SocketAddr>,
|
||||
|
||||
/// Advertised owner-control address encoded into the local-only bootstrap token.
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) control_advertise_addr: Option<std::net::SocketAddr>,
|
||||
pub control_advertise_addr: Option<std::net::SocketAddr>,
|
||||
|
||||
/// Fail startup if owner attestation cannot be loaded or signed.
|
||||
#[arg(long)]
|
||||
pub(crate) owner_required: bool,
|
||||
pub owner_required: bool,
|
||||
|
||||
/// Optional human label attached to this node certificate.
|
||||
#[arg(long)]
|
||||
pub(crate) node_label: Option<String>,
|
||||
pub node_label: Option<String>,
|
||||
|
||||
/// Override peer ownership trust policy.
|
||||
#[arg(long, value_enum)]
|
||||
pub(crate) trust_policy: Option<TrustPolicy>,
|
||||
pub trust_policy: Option<TrustPolicy>,
|
||||
|
||||
/// Add trusted owner IDs on top of the local trust store.
|
||||
#[arg(long)]
|
||||
pub(crate) trust_owner: Vec<String>,
|
||||
pub trust_owner: Vec<String>,
|
||||
|
||||
/// Internal: set when this node joined via Nostr discovery (not --join).
|
||||
#[arg(skip)]
|
||||
pub(crate) nostr_discovery: bool,
|
||||
pub nostr_discovery: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn validate_discovery_mode_args(cli: &Cli) -> anyhow::Result<()> {
|
||||
pub fn validate_discovery_mode_args(cli: &Cli) -> anyhow::Result<()> {
|
||||
if cli.mesh_discovery_mode != MeshDiscoveryMode::Mdns {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -621,7 +667,7 @@ pub(crate) fn validate_discovery_mode_args(cli: &Cli) -> anyhow::Result<()> {
|
|||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum Command {
|
||||
pub enum Command {
|
||||
/// Manage model storage, migration, and update checks.
|
||||
Models {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -642,7 +688,7 @@ pub(crate) enum Command {
|
|||
version: Option<String>,
|
||||
/// Install this release bundle flavor instead of the default installed flavor.
|
||||
#[arg(long, value_enum, conflicts_with = "detect_flavor")]
|
||||
flavor: Option<crate::system::backend::BinaryFlavor>,
|
||||
flavor: Option<BinaryFlavor>,
|
||||
/// Re-detect the best host backend flavor before selecting the release bundle.
|
||||
#[arg(long, conflicts_with = "flavor")]
|
||||
detect_flavor: bool,
|
||||
|
|
@ -656,16 +702,18 @@ pub(crate) enum Command {
|
|||
#[command(subcommand)]
|
||||
command: Option<GpuCommand>,
|
||||
},
|
||||
/// Inspect and manage local runtime-served models.
|
||||
#[command(hide = true)]
|
||||
/// Inspect and manage native runtimes.
|
||||
Runtime {
|
||||
#[command(subcommand)]
|
||||
command: Option<RuntimeCommand>,
|
||||
},
|
||||
/// Diagnose local mesh, runtime, and split-readiness problems.
|
||||
Doctor {
|
||||
/// Print machine-readable JSON for the default doctor report.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
#[command(subcommand)]
|
||||
command: DoctorCommand,
|
||||
command: Option<DoctorCommand>,
|
||||
},
|
||||
/// Load a local model into a running mesh-llm instance.
|
||||
Load {
|
||||
|
|
@ -870,7 +918,7 @@ pub(crate) enum Command {
|
|||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum PluginCommand {
|
||||
pub enum PluginCommand {
|
||||
/// Install a native plugin from the catalog or a GitHub repository.
|
||||
Install {
|
||||
/// Plugin catalog name, GitHub owner/repo, or GitHub URL.
|
||||
|
|
@ -911,7 +959,7 @@ pub(crate) enum PluginCommand {
|
|||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum SkillCommand {
|
||||
pub enum SkillCommand {
|
||||
/// Install skills exposed by installed plugins into supported agent skill folders.
|
||||
Install {
|
||||
/// Agent to install for. Repeat to install to several agents.
|
||||
|
|
@ -930,7 +978,7 @@ pub(crate) enum SkillCommand {
|
|||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
||||
pub(crate) enum SkillAgentArg {
|
||||
pub enum SkillAgentArg {
|
||||
Global,
|
||||
Goose,
|
||||
Pi,
|
||||
|
|
@ -939,21 +987,8 @@ pub(crate) enum SkillAgentArg {
|
|||
Claude,
|
||||
}
|
||||
|
||||
impl From<SkillAgentArg> for mesh_llm_plugin_manager::SkillAgent {
|
||||
fn from(value: SkillAgentArg) -> Self {
|
||||
match value {
|
||||
SkillAgentArg::Global => Self::Global,
|
||||
SkillAgentArg::Goose => Self::Goose,
|
||||
SkillAgentArg::Pi => Self::Pi,
|
||||
SkillAgentArg::Codex => Self::Codex,
|
||||
SkillAgentArg::Opencode => Self::Opencode,
|
||||
SkillAgentArg::Claude => Self::Claude,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum DoctorCommand {
|
||||
pub enum DoctorCommand {
|
||||
/// Diagnose split-readiness for a model on a running local mesh node.
|
||||
Split {
|
||||
/// Model ref/name to diagnose.
|
||||
|
|
@ -972,19 +1007,19 @@ pub(crate) enum DoctorCommand {
|
|||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum RuntimeSurface {
|
||||
pub enum RuntimeSurface {
|
||||
Serve,
|
||||
Client,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct NormalizedRuntimeArgs {
|
||||
pub(crate) original: Vec<OsString>,
|
||||
pub(crate) normalized: Vec<OsString>,
|
||||
pub(crate) explicit_surface: Option<RuntimeSurface>,
|
||||
pub struct NormalizedRuntimeArgs {
|
||||
pub original: Vec<OsString>,
|
||||
pub normalized: Vec<OsString>,
|
||||
pub explicit_surface: Option<RuntimeSurface>,
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_runtime_surface_args<I, S>(args: I) -> NormalizedRuntimeArgs
|
||||
pub fn normalize_runtime_surface_args<I, S>(args: I) -> NormalizedRuntimeArgs
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
|
|
@ -1103,7 +1138,7 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_runtime_surface_warning(
|
||||
pub fn legacy_runtime_surface_warning(
|
||||
cli: &Cli,
|
||||
original_args: &[OsString],
|
||||
explicit_surface: Option<RuntimeSurface>,
|
||||
|
|
@ -1179,7 +1214,7 @@ fn shell_display(arg: &OsString) -> String {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn assert_mesh_requirements_docs_examples_parse() {
|
||||
pub fn assert_mesh_requirements_docs_examples_parse() {
|
||||
let unrestricted_args =
|
||||
normalize_runtime_surface_args(["mesh-llm", "serve", "--model", "Qwen3-8B-Q4_K_M"]);
|
||||
let unrestricted = Cli::parse_from(unrestricted_args.normalized.clone());
|
||||
|
|
@ -1247,7 +1282,7 @@ pub(crate) fn assert_mesh_requirements_docs_examples_parse() {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::models::{ModelSearchSort, ModelsCommand};
|
||||
use crate::models::{ModelSearchSort, ModelsCommand};
|
||||
use clap::{CommandFactory, Parser, error::ErrorKind};
|
||||
|
||||
#[test]
|
||||
|
|
@ -1745,10 +1780,7 @@ mod tests {
|
|||
]);
|
||||
let cli = Cli::parse_from(normalized.normalized);
|
||||
|
||||
assert_eq!(
|
||||
cli.mesh_discovery_mode,
|
||||
crate::network::discovery::MeshDiscoveryMode::Mdns
|
||||
);
|
||||
assert_eq!(cli.mesh_discovery_mode, MeshDiscoveryMode::Mdns);
|
||||
assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Serve));
|
||||
}
|
||||
|
||||
|
|
@ -1757,10 +1789,7 @@ mod tests {
|
|||
let normalized = normalize_runtime_surface_args(["mesh-llm", "serve", "--auto"]);
|
||||
let cli = Cli::parse_from(normalized.normalized);
|
||||
|
||||
assert_eq!(
|
||||
cli.mesh_discovery_mode,
|
||||
crate::network::discovery::MeshDiscoveryMode::Nostr
|
||||
);
|
||||
assert_eq!(cli.mesh_discovery_mode, MeshDiscoveryMode::Nostr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1770,10 +1799,7 @@ mod tests {
|
|||
let cli = Cli::parse_from(normalized.normalized);
|
||||
|
||||
assert!(cli.client);
|
||||
assert_eq!(
|
||||
cli.mesh_discovery_mode,
|
||||
crate::network::discovery::MeshDiscoveryMode::Mdns
|
||||
);
|
||||
assert_eq!(cli.mesh_discovery_mode, MeshDiscoveryMode::Mdns);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1,17 +1,86 @@
|
|||
use clap::Subcommand;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::cli::MeshGuardrailCliMode;
|
||||
use crate::MeshGuardrailCliMode;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum RuntimeCommand {
|
||||
pub enum RuntimeCommand {
|
||||
/// List available or installed native runtimes.
|
||||
List {
|
||||
/// List release-manifest or bundled runtimes instead of installed runtimes.
|
||||
#[arg(long, conflicts_with = "installed")]
|
||||
available: bool,
|
||||
/// List installed native runtimes. This is the default when no list mode is supplied.
|
||||
#[arg(long, conflicts_with = "available")]
|
||||
installed: bool,
|
||||
/// Release manifest JSON to inspect.
|
||||
#[arg(long)]
|
||||
manifest: Option<PathBuf>,
|
||||
/// Packaged native runtime directory to inspect. Repeatable.
|
||||
#[arg(long = "bundle-dir")]
|
||||
bundle_dirs: Vec<PathBuf>,
|
||||
/// Override the native runtime cache root.
|
||||
#[arg(long)]
|
||||
cache_dir: Option<PathBuf>,
|
||||
/// Print machine-readable JSON.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Install the recommended native runtime, or an explicit flavor/runtime ID.
|
||||
Install {
|
||||
/// Optional runtime flavor or native runtime ID. Omit to install the recommended runtime.
|
||||
runtime: Option<String>,
|
||||
/// Release manifest JSON to resolve against.
|
||||
#[arg(long)]
|
||||
manifest: Option<PathBuf>,
|
||||
/// Packaged native runtime directory to install from. Repeatable.
|
||||
#[arg(long = "bundle-dir")]
|
||||
bundle_dirs: Vec<PathBuf>,
|
||||
/// Override the native runtime cache root.
|
||||
#[arg(long)]
|
||||
cache_dir: Option<PathBuf>,
|
||||
/// Print machine-readable JSON.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Remove an installed native runtime.
|
||||
Remove {
|
||||
/// Native runtime ID to remove.
|
||||
native_runtime_id: String,
|
||||
/// MeshLLM version. Defaults to the running MeshLLM version.
|
||||
#[arg(long)]
|
||||
mesh_version: Option<String>,
|
||||
/// Override the native runtime cache root.
|
||||
#[arg(long)]
|
||||
cache_dir: Option<PathBuf>,
|
||||
/// Print machine-readable JSON.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Prune old native runtimes from the cache.
|
||||
Prune {
|
||||
/// Remove every runtime not matching the active MeshLLM version.
|
||||
#[arg(long)]
|
||||
active_only: bool,
|
||||
/// Override the active MeshLLM version. Defaults to the running version.
|
||||
#[arg(long)]
|
||||
mesh_version: Option<String>,
|
||||
/// Override the native runtime cache root.
|
||||
#[arg(long)]
|
||||
cache_dir: Option<PathBuf>,
|
||||
/// Print machine-readable JSON.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Show local model status on a running mesh-llm instance.
|
||||
#[command(hide = true)]
|
||||
Status {
|
||||
/// Console/API port of the running mesh-llm instance (default: 3131)
|
||||
#[arg(long, default_value = "3131")]
|
||||
port: u16,
|
||||
},
|
||||
/// Show the local-only owner-control bootstrap policy for a running mesh-llm instance.
|
||||
#[command(hide = true)]
|
||||
Bootstrap {
|
||||
/// Console/API port of the running mesh-llm instance (default: 3131)
|
||||
#[arg(long, default_value = "3131")]
|
||||
|
|
@ -21,6 +90,7 @@ pub(crate) enum RuntimeCommand {
|
|||
json: bool,
|
||||
},
|
||||
/// Fetch config from a remote owner-control endpoint through the local management API.
|
||||
#[command(hide = true)]
|
||||
GetConfig {
|
||||
/// Explicit owner-control endpoint token for the target node.
|
||||
#[arg(long)]
|
||||
|
|
@ -33,6 +103,7 @@ pub(crate) enum RuntimeCommand {
|
|||
json: bool,
|
||||
},
|
||||
/// Refresh local inventory on a remote owner-control endpoint through the local management API.
|
||||
#[command(hide = true)]
|
||||
RefreshInventory {
|
||||
/// Explicit owner-control endpoint token for the target node.
|
||||
#[arg(long)]
|
||||
|
|
@ -45,6 +116,7 @@ pub(crate) enum RuntimeCommand {
|
|||
json: bool,
|
||||
},
|
||||
/// Apply config to a remote owner-control endpoint through the local management API.
|
||||
#[command(hide = true)]
|
||||
ApplyConfig {
|
||||
/// Explicit owner-control endpoint token for the target node.
|
||||
#[arg(long)]
|
||||
|
|
@ -63,6 +135,7 @@ pub(crate) enum RuntimeCommand {
|
|||
json: bool,
|
||||
},
|
||||
/// Load a local model into a running mesh-llm instance.
|
||||
#[command(hide = true)]
|
||||
Load {
|
||||
/// Model name/path/url to load
|
||||
name: String,
|
||||
|
|
@ -71,7 +144,7 @@ pub(crate) enum RuntimeCommand {
|
|||
port: u16,
|
||||
},
|
||||
/// Unload a local model from a running mesh-llm instance.
|
||||
#[command(alias = "drop")]
|
||||
#[command(alias = "drop", hide = true)]
|
||||
Unload {
|
||||
/// Model name to unload
|
||||
name: String,
|
||||
|
|
@ -80,6 +153,7 @@ pub(crate) enum RuntimeCommand {
|
|||
port: u16,
|
||||
},
|
||||
/// Set mesh guardrail mode on running Skippy-backed models without restart.
|
||||
#[command(hide = true)]
|
||||
Guardrails {
|
||||
/// Guardrail mode to apply to active Skippy-backed OpenAI surfaces.
|
||||
#[arg(long, value_enum)]
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
pub(crate) fn single_quote(value: &str) -> String {
|
||||
pub fn single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
47
crates/mesh-llm-commands/Cargo.toml
Normal file
47
crates/mesh-llm-commands/Cargo.toml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
[package]
|
||||
name = "mesh-llm-commands"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
version.workspace = true
|
||||
description = "User-facing command handlers for mesh-llm"
|
||||
repository = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
homepage = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
readme = "README.md"
|
||||
keywords = ["llm", "mesh", "cli"]
|
||||
categories = ["command-line-interface"]
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "6.0.0"
|
||||
hf_hub = { package = "hf-hub", version = "1.0.0-rc.1", default-features = false, features = ["blocking"] }
|
||||
hex = "0.4.3"
|
||||
iroh = "1.0.0-rc.0"
|
||||
json5 = "1.3.1"
|
||||
mesh-llm-cli = { path = "../mesh-llm-cli", version = "0.68.0" }
|
||||
mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.68.0" }
|
||||
mesh-llm-plugin-manager = { path = "../mesh-llm-plugin-manager", version = "0.68.0" }
|
||||
mesh-llm-identity = { path = "../mesh-llm-identity", version = "0.68.0", features = ["host-io"] }
|
||||
mesh-llm-runtime-install = { path = "../mesh-llm-runtime-install", version = "0.68.0" }
|
||||
mesh-llm-system = { path = "../mesh-llm-system", version = "0.68.0", features = ["skippy-devices"] }
|
||||
mesh-llm-tui = { path = "../mesh-llm-tui", version = "0.68.0" }
|
||||
model-package = { path = "../model-package", version = "0.68.0" }
|
||||
model-ref = { path = "../model-ref", version = "0.68.0" }
|
||||
rpassword = "5"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml = "0.9"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-stream = "0.1"
|
||||
url = "2"
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
rand = "0.10"
|
||||
serial_test = "3"
|
||||
tempfile = "3"
|
||||
10
crates/mesh-llm-commands/README.md
Normal file
10
crates/mesh-llm-commands/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# mesh-llm-commands
|
||||
|
||||
`mesh-llm-commands` owns command handlers that can run without depending on
|
||||
`mesh-llm-host-runtime`.
|
||||
|
||||
This crate is part of the host-runtime decomposition: command handlers move
|
||||
here first when they can be expressed in terms of lower-level domain crates.
|
||||
The shipped `mesh-llm` binary can dispatch these handlers directly, while
|
||||
`mesh-llm-host-runtime` keeps temporary compatibility shims until command
|
||||
dispatch fully leaves the host runtime.
|
||||
1182
crates/mesh-llm-commands/src/agent_cli.rs
Normal file
1182
crates/mesh-llm-commands/src/agent_cli.rs
Normal file
File diff suppressed because it is too large
Load diff
1064
crates/mesh-llm-commands/src/agent_cli/tests.rs
Normal file
1064
crates/mesh-llm-commands/src/agent_cli/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,17 +4,16 @@ use std::result::Result as StdResult;
|
|||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use iroh::{EndpointId, SecretKey};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::cli::TrustCommand;
|
||||
use crate::crypto::{
|
||||
use mesh_llm_cli::{AuthCommand, TrustCommand};
|
||||
use mesh_llm_identity::{
|
||||
KEYCHAIN_SERVICE, OwnerKeychainLoadError, OwnerKeypair, SignedNodeOwnership, TrustPolicy,
|
||||
TrustStore, default_keystore_path, default_node_ownership_path, default_trust_store_path,
|
||||
keystore_exists, keystore_metadata, load_keystore, load_node_ownership,
|
||||
load_owner_keypair_from_keychain, load_trust_store, save_keystore, save_keystore_with_keychain,
|
||||
TrustStore, default_keystore_path, default_node_key_path, default_node_ownership_path,
|
||||
default_trust_store_path, keystore_exists, keystore_metadata, load_keystore,
|
||||
load_node_key_bytes_from_path, load_node_ownership, load_owner_keypair_from_keychain,
|
||||
load_trust_store, save_keystore, save_keystore_with_keychain, save_node_key_bytes_to_path,
|
||||
save_node_ownership, save_trust_store, sign_node_ownership, verify_node_ownership,
|
||||
};
|
||||
use crate::mesh::{default_node_key_path, load_node_key_from_path, save_node_key_to_path};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn now_unix_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
|
|
@ -33,7 +32,131 @@ fn resolve_owner_key_path(owner_key: Option<PathBuf>) -> Result<PathBuf> {
|
|||
fn resolve_node_key_path(node_key: Option<PathBuf>) -> Result<PathBuf> {
|
||||
match node_key {
|
||||
Some(path) => Ok(path),
|
||||
None => default_node_key_path(),
|
||||
None => Ok(default_node_key_path()?),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_node_key_from_path(path: &Path) -> Result<SecretKey> {
|
||||
Ok(SecretKey::from_bytes(&load_node_key_bytes_from_path(path)?))
|
||||
}
|
||||
|
||||
fn save_node_key_to_path(path: &Path, key: &SecretKey) -> Result<()> {
|
||||
save_node_key_bytes_to_path(path, &key.to_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_auth_command(command: &AuthCommand) -> Result<()> {
|
||||
match command {
|
||||
AuthCommand::Init {
|
||||
owner_key,
|
||||
force,
|
||||
no_passphrase,
|
||||
keychain,
|
||||
} => run_init(owner_key.clone(), *force, *no_passphrase, *keychain),
|
||||
AuthCommand::Status {
|
||||
owner_key,
|
||||
node_key,
|
||||
node_ownership,
|
||||
trust_store,
|
||||
} => run_status(
|
||||
owner_key.clone(),
|
||||
node_key.clone(),
|
||||
node_ownership.clone(),
|
||||
trust_store.clone(),
|
||||
),
|
||||
AuthCommand::SignNode {
|
||||
owner_key,
|
||||
node_key,
|
||||
out,
|
||||
hostname_hint,
|
||||
node_label,
|
||||
expires_in_hours,
|
||||
} => run_sign_node(
|
||||
owner_key.clone(),
|
||||
node_key.clone(),
|
||||
out.clone(),
|
||||
node_label.clone(),
|
||||
hostname_hint.clone(),
|
||||
*expires_in_hours,
|
||||
),
|
||||
AuthCommand::RenewNode {
|
||||
owner_key,
|
||||
node_key,
|
||||
out,
|
||||
hostname_hint,
|
||||
node_label,
|
||||
expires_in_hours,
|
||||
} => run_renew_node(
|
||||
owner_key.clone(),
|
||||
node_key.clone(),
|
||||
out.clone(),
|
||||
node_label.clone(),
|
||||
hostname_hint.clone(),
|
||||
*expires_in_hours,
|
||||
),
|
||||
AuthCommand::VerifyNode {
|
||||
file,
|
||||
node_id,
|
||||
trust_store,
|
||||
trust_policy,
|
||||
} => run_verify_node(
|
||||
file.clone(),
|
||||
node_id.clone(),
|
||||
trust_store.clone(),
|
||||
trust_policy.map(cli_trust_policy_to_identity),
|
||||
),
|
||||
AuthCommand::RotateNode {
|
||||
owner_key,
|
||||
node_key,
|
||||
out,
|
||||
hostname_hint,
|
||||
node_label,
|
||||
expires_in_hours,
|
||||
revoke_current,
|
||||
reason,
|
||||
trust_store,
|
||||
} => run_rotate_node(
|
||||
owner_key.clone(),
|
||||
node_key.clone(),
|
||||
out.clone(),
|
||||
node_label.clone(),
|
||||
hostname_hint.clone(),
|
||||
*expires_in_hours,
|
||||
*revoke_current,
|
||||
reason.clone(),
|
||||
trust_store.clone(),
|
||||
),
|
||||
AuthCommand::RevokeOwner {
|
||||
owner_id,
|
||||
reason,
|
||||
trust_store,
|
||||
} => run_revoke_owner(owner_id.clone(), reason.clone(), trust_store.clone()),
|
||||
AuthCommand::RevokeNode {
|
||||
cert_id,
|
||||
node_id,
|
||||
reason,
|
||||
trust_store,
|
||||
} => run_revoke_node(
|
||||
cert_id.clone(),
|
||||
node_id.clone(),
|
||||
reason.clone(),
|
||||
trust_store.clone(),
|
||||
),
|
||||
AuthCommand::RotateOwner {
|
||||
owner_key,
|
||||
no_passphrase,
|
||||
force,
|
||||
} => run_rotate_owner(owner_key.clone(), *no_passphrase, *force),
|
||||
AuthCommand::Trust { command } => run_trust_command(command),
|
||||
}
|
||||
}
|
||||
|
||||
fn cli_trust_policy_to_identity(value: mesh_llm_cli::TrustPolicy) -> TrustPolicy {
|
||||
match value {
|
||||
mesh_llm_cli::TrustPolicy::Off => TrustPolicy::Off,
|
||||
mesh_llm_cli::TrustPolicy::PreferOwned => TrustPolicy::PreferOwned,
|
||||
mesh_llm_cli::TrustPolicy::RequireOwned => TrustPolicy::RequireOwned,
|
||||
mesh_llm_cli::TrustPolicy::Allowlist => TrustPolicy::Allowlist,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +210,7 @@ fn resolve_keystore_passphrase(path: &Path) -> Result<Option<Zeroizing<String>>>
|
|||
return Ok(Some(Zeroizing::new(passphrase)));
|
||||
}
|
||||
|
||||
Err(crate::crypto::CryptoError::MissingPassphrase.into())
|
||||
Err(mesh_llm_identity::CryptoError::MissingPassphrase.into())
|
||||
}
|
||||
|
||||
fn load_owner_keypair_from_path(path: &Path) -> Result<OwnerKeypair> {
|
||||
|
|
@ -96,12 +219,14 @@ fn load_owner_keypair_from_path(path: &Path) -> Result<OwnerKeypair> {
|
|||
match load_owner_keypair_from_keychain(path) {
|
||||
Ok(keypair) => return Ok(keypair),
|
||||
Err(OwnerKeychainLoadError::NoEntry)
|
||||
| Err(OwnerKeychainLoadError::Crypto(crate::crypto::CryptoError::DecryptionFailed))
|
||||
| Err(OwnerKeychainLoadError::Crypto(
|
||||
crate::crypto::CryptoError::KeychainUnavailable { .. },
|
||||
mesh_llm_identity::CryptoError::DecryptionFailed,
|
||||
))
|
||||
| Err(OwnerKeychainLoadError::Crypto(
|
||||
crate::crypto::CryptoError::KeychainAccessDenied { .. },
|
||||
mesh_llm_identity::CryptoError::KeychainUnavailable { .. },
|
||||
))
|
||||
| Err(OwnerKeychainLoadError::Crypto(
|
||||
mesh_llm_identity::CryptoError::KeychainAccessDenied { .. },
|
||||
)) => {}
|
||||
Err(OwnerKeychainLoadError::Crypto(err)) => {
|
||||
return Err(err)
|
||||
|
|
@ -169,7 +294,7 @@ pub(crate) fn run_init(
|
|||
}
|
||||
|
||||
let use_keychain = if keychain {
|
||||
if !crate::crypto::keychain_available() {
|
||||
if !mesh_llm_identity::keychain_available() {
|
||||
bail!(
|
||||
"No OS keychain backend is available on this host.\n\
|
||||
Retry without --keychain to set a passphrase, or with --no-passphrase \
|
||||
|
|
@ -179,7 +304,7 @@ pub(crate) fn run_init(
|
|||
true
|
||||
} else {
|
||||
let available =
|
||||
(!existing_keystore && !no_passphrase) && crate::crypto::keychain_available();
|
||||
(!existing_keystore && !no_passphrase) && mesh_llm_identity::keychain_available();
|
||||
should_default_to_keychain(existing_keystore, no_passphrase, available)
|
||||
};
|
||||
|
||||
|
|
@ -752,16 +877,16 @@ fn encrypted_keystore_keychain_status(error: OwnerKeychainLoadError) -> String {
|
|||
passphrase when the owner keystore is consumed)"
|
||||
.into()
|
||||
}
|
||||
OwnerKeychainLoadError::Crypto(crate::crypto::CryptoError::DecryptionFailed) => {
|
||||
OwnerKeychainLoadError::Crypto(mesh_llm_identity::CryptoError::DecryptionFailed) => {
|
||||
"Keystore: encrypted (keychain entry could not unlock this keystore; \
|
||||
provide the passphrase when the owner keystore is consumed or remove the stale \
|
||||
keychain entry for this path)"
|
||||
.into()
|
||||
}
|
||||
OwnerKeychainLoadError::Crypto(crate::crypto::CryptoError::KeychainUnavailable {
|
||||
OwnerKeychainLoadError::Crypto(mesh_llm_identity::CryptoError::KeychainUnavailable {
|
||||
reason,
|
||||
}) => format!("Keystore: encrypted (keychain unavailable: {reason})"),
|
||||
OwnerKeychainLoadError::Crypto(crate::crypto::CryptoError::KeychainAccessDenied {
|
||||
OwnerKeychainLoadError::Crypto(mesh_llm_identity::CryptoError::KeychainAccessDenied {
|
||||
reason,
|
||||
}) => format!(
|
||||
"Keystore: encrypted (keychain is locked or access was denied: {reason}; \
|
||||
|
|
@ -781,143 +906,4 @@ fn should_default_to_keychain(
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
fn defaults_to_keychain_for_new_keystore_when_available() {
|
||||
assert!(should_default_to_keychain(false, false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_default_to_keychain_for_existing_keystore() {
|
||||
assert!(!should_default_to_keychain(true, false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_default_to_keychain_when_unavailable() {
|
||||
assert!(!should_default_to_keychain(false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_default_to_keychain_with_no_passphrase() {
|
||||
assert!(!should_default_to_keychain(false, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_stale_keychain_entry_as_encrypted_keystore() {
|
||||
let message = encrypted_keystore_keychain_status(OwnerKeychainLoadError::Crypto(
|
||||
crate::crypto::CryptoError::DecryptionFailed,
|
||||
));
|
||||
|
||||
assert!(message.contains("keychain entry could not unlock this keystore"));
|
||||
assert!(message.contains("remove the stale keychain entry for this path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn force_keychain_save_failure_restores_previous_secret() {
|
||||
if !crate::crypto::keychain_available() {
|
||||
eprintln!("keychain backend unavailable, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp_dir =
|
||||
std::env::temp_dir().join(format!("mesh-llm-force-rollback-{}", rand::random::<u64>()));
|
||||
std::fs::create_dir_all(&tmp_dir).unwrap();
|
||||
let blocking_file = tmp_dir.join("blocker");
|
||||
std::fs::write(&blocking_file, b"not a directory").unwrap();
|
||||
let bad_path = blocking_file.join("owner-keystore.json");
|
||||
|
||||
let account = crate::crypto::owner_keychain_account_for_path(&bad_path);
|
||||
let previous_secret = "previous-unlock-secret-do-not-lose";
|
||||
crate::crypto::keychain_set(KEYCHAIN_SERVICE, &account, previous_secret).unwrap();
|
||||
|
||||
let result = run_init(Some(bad_path.clone()), true, false, true);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"run_init must fail when save cannot succeed"
|
||||
);
|
||||
|
||||
let restored = crate::crypto::keychain_get(KEYCHAIN_SERVICE, &account).unwrap();
|
||||
assert_eq!(
|
||||
restored.as_deref(),
|
||||
Some(previous_secret),
|
||||
"previous keychain secret must be restored after failed force-init"
|
||||
);
|
||||
|
||||
crate::crypto::keychain_delete(KEYCHAIN_SERVICE, &account).ok();
|
||||
std::fs::remove_dir_all(&tmp_dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn fresh_keychain_save_failure_leaves_no_orphan() {
|
||||
if !crate::crypto::keychain_available() {
|
||||
eprintln!("keychain backend unavailable, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp_dir =
|
||||
std::env::temp_dir().join(format!("mesh-llm-fresh-rollback-{}", rand::random::<u64>()));
|
||||
std::fs::create_dir_all(&tmp_dir).unwrap();
|
||||
let blocking_file = tmp_dir.join("blocker");
|
||||
std::fs::write(&blocking_file, b"not a directory").unwrap();
|
||||
let bad_path = blocking_file.join("owner-keystore.json");
|
||||
|
||||
let account = crate::crypto::owner_keychain_account_for_path(&bad_path);
|
||||
crate::crypto::keychain_delete(KEYCHAIN_SERVICE, &account).ok();
|
||||
|
||||
let result = run_init(Some(bad_path.clone()), false, false, true);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"run_init must fail when save cannot succeed"
|
||||
);
|
||||
|
||||
let residual = crate::crypto::keychain_get(KEYCHAIN_SERVICE, &account).unwrap();
|
||||
assert_eq!(
|
||||
residual, None,
|
||||
"a fresh init failure must leave no keychain entry behind"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&tmp_dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn init_defaults_to_keychain_then_load_round_trip() {
|
||||
if !crate::crypto::keychain_available() {
|
||||
eprintln!("keychain backend unavailable, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("mesh-llm-keychain-rt-{}", rand::random::<u64>()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("owner-keystore.json");
|
||||
|
||||
run_init(Some(path.clone()), false, false, false)
|
||||
.expect("auth init should default to keychain when available");
|
||||
|
||||
assert!(path.exists(), "keystore file should exist");
|
||||
let info = keystore_metadata(&path).unwrap();
|
||||
assert!(
|
||||
info.encrypted,
|
||||
"keystore should be encrypted when using keychain"
|
||||
);
|
||||
|
||||
let account = crate::crypto::owner_keychain_account_for_path(&path);
|
||||
let stored = crate::crypto::keychain_get(KEYCHAIN_SERVICE, &account).unwrap();
|
||||
assert!(
|
||||
stored.is_some(),
|
||||
"keychain must have a passphrase entry for this keystore path"
|
||||
);
|
||||
|
||||
let kp = load_owner_keypair_from_keychain(&path).expect("load via keychain must succeed");
|
||||
assert_eq!(kp.owner_id(), info.owner_id);
|
||||
|
||||
crate::crypto::keychain_delete(KEYCHAIN_SERVICE, &account).ok();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
137
crates/mesh-llm-commands/src/auth/tests.rs
Normal file
137
crates/mesh-llm-commands/src/auth/tests.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
fn defaults_to_keychain_for_new_keystore_when_available() {
|
||||
assert!(should_default_to_keychain(false, false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_default_to_keychain_for_existing_keystore() {
|
||||
assert!(!should_default_to_keychain(true, false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_default_to_keychain_when_unavailable() {
|
||||
assert!(!should_default_to_keychain(false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_default_to_keychain_with_no_passphrase() {
|
||||
assert!(!should_default_to_keychain(false, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_stale_keychain_entry_as_encrypted_keystore() {
|
||||
let message = encrypted_keystore_keychain_status(OwnerKeychainLoadError::Crypto(
|
||||
mesh_llm_identity::CryptoError::DecryptionFailed,
|
||||
));
|
||||
|
||||
assert!(message.contains("keychain entry could not unlock this keystore"));
|
||||
assert!(message.contains("remove the stale keychain entry for this path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn force_keychain_save_failure_restores_previous_secret() {
|
||||
if !mesh_llm_identity::keychain_available() {
|
||||
eprintln!("keychain backend unavailable, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp_dir =
|
||||
std::env::temp_dir().join(format!("mesh-llm-force-rollback-{}", rand::random::<u64>()));
|
||||
std::fs::create_dir_all(&tmp_dir).unwrap();
|
||||
let blocking_file = tmp_dir.join("blocker");
|
||||
std::fs::write(&blocking_file, b"not a directory").unwrap();
|
||||
let bad_path = blocking_file.join("owner-keystore.json");
|
||||
|
||||
let account = mesh_llm_identity::owner_keychain_account_for_path(&bad_path);
|
||||
let previous_secret = "previous-unlock-secret-do-not-lose";
|
||||
mesh_llm_identity::keychain_set(KEYCHAIN_SERVICE, &account, previous_secret).unwrap();
|
||||
|
||||
let result = run_init(Some(bad_path.clone()), true, false, true);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"run_init must fail when save cannot succeed"
|
||||
);
|
||||
|
||||
let restored = mesh_llm_identity::keychain_get(KEYCHAIN_SERVICE, &account).unwrap();
|
||||
assert_eq!(
|
||||
restored.as_deref(),
|
||||
Some(previous_secret),
|
||||
"previous keychain secret must be restored after failed force-init"
|
||||
);
|
||||
|
||||
mesh_llm_identity::keychain_delete(KEYCHAIN_SERVICE, &account).ok();
|
||||
std::fs::remove_dir_all(&tmp_dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn fresh_keychain_save_failure_leaves_no_orphan() {
|
||||
if !mesh_llm_identity::keychain_available() {
|
||||
eprintln!("keychain backend unavailable, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp_dir =
|
||||
std::env::temp_dir().join(format!("mesh-llm-fresh-rollback-{}", rand::random::<u64>()));
|
||||
std::fs::create_dir_all(&tmp_dir).unwrap();
|
||||
let blocking_file = tmp_dir.join("blocker");
|
||||
std::fs::write(&blocking_file, b"not a directory").unwrap();
|
||||
let bad_path = blocking_file.join("owner-keystore.json");
|
||||
|
||||
let account = mesh_llm_identity::owner_keychain_account_for_path(&bad_path);
|
||||
mesh_llm_identity::keychain_delete(KEYCHAIN_SERVICE, &account).ok();
|
||||
|
||||
let result = run_init(Some(bad_path.clone()), false, false, true);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"run_init must fail when save cannot succeed"
|
||||
);
|
||||
|
||||
let residual = mesh_llm_identity::keychain_get(KEYCHAIN_SERVICE, &account).unwrap();
|
||||
assert_eq!(
|
||||
residual, None,
|
||||
"a fresh init failure must leave no keychain entry behind"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&tmp_dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn init_defaults_to_keychain_then_load_round_trip() {
|
||||
if !mesh_llm_identity::keychain_available() {
|
||||
eprintln!("keychain backend unavailable, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("mesh-llm-keychain-rt-{}", rand::random::<u64>()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("owner-keystore.json");
|
||||
|
||||
run_init(Some(path.clone()), false, false, false)
|
||||
.expect("auth init should default to keychain when available");
|
||||
|
||||
assert!(path.exists(), "keystore file should exist");
|
||||
let info = keystore_metadata(&path).unwrap();
|
||||
assert!(
|
||||
info.encrypted,
|
||||
"keystore should be encrypted when using keychain"
|
||||
);
|
||||
|
||||
let account = mesh_llm_identity::owner_keychain_account_for_path(&path);
|
||||
let stored = mesh_llm_identity::keychain_get(KEYCHAIN_SERVICE, &account).unwrap();
|
||||
assert!(
|
||||
stored.is_some(),
|
||||
"keychain must have a passphrase entry for this keystore path"
|
||||
);
|
||||
|
||||
let kp = load_owner_keypair_from_keychain(&path).expect("load via keychain must succeed");
|
||||
assert_eq!(kp.owner_id(), info.owner_id);
|
||||
|
||||
mesh_llm_identity::keychain_delete(KEYCHAIN_SERVICE, &account).ok();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
use anyhow::Result;
|
||||
use mesh_llm_cli::benchmark::{BenchmarkCommand, GpuBenchmarkBackend, PromptImportSource};
|
||||
use mesh_llm_system::benchmark;
|
||||
use mesh_llm_system::benchmark_prompts::{self, ImportPromptsArgs};
|
||||
|
||||
use crate::cli::benchmark::{BenchmarkCommand, GpuBenchmarkBackend, PromptImportSource};
|
||||
use crate::system::benchmark;
|
||||
use crate::system::benchmark_prompts::{self, ImportPromptsArgs};
|
||||
|
||||
pub(crate) async fn dispatch_benchmark_command(command: &BenchmarkCommand) -> Result<()> {
|
||||
pub async fn dispatch_benchmark_command(command: &BenchmarkCommand) -> Result<()> {
|
||||
match command {
|
||||
BenchmarkCommand::ImportPrompts {
|
||||
source,
|
||||
|
|
@ -17,7 +16,7 @@ pub(crate) async fn dispatch_benchmark_command(command: &BenchmarkCommand) -> Re
|
|||
limit: *limit,
|
||||
max_tokens: *max_tokens,
|
||||
output: output.clone(),
|
||||
user_agent_version: crate::VERSION,
|
||||
user_agent_version: env!("CARGO_PKG_VERSION"),
|
||||
};
|
||||
benchmark_prompts::import_prompt_corpus(args).await
|
||||
}
|
||||
|
|
@ -1,21 +1,19 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cli::GpuCommand;
|
||||
|
||||
use crate::system::{
|
||||
use mesh_llm_cli::GpuCommand;
|
||||
use mesh_llm_system::{
|
||||
benchmark::{self, SavedBenchmark},
|
||||
hardware::{self, GpuFacts, HardwareSurvey},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub(crate) fn dispatch_gpu_command(json_output: bool, command: Option<&GpuCommand>) -> Result<()> {
|
||||
pub fn dispatch_gpu_command(json_output: bool, command: Option<&GpuCommand>) -> Result<()> {
|
||||
match command {
|
||||
Some(GpuCommand::Detect { json }) => run_gpu_benchmark(json_output || *json),
|
||||
None => run_gpus(json_output),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_gpus(json_output: bool) -> Result<()> {
|
||||
pub fn run_gpus(json_output: bool) -> Result<()> {
|
||||
let mut hw = hardware::survey();
|
||||
attach_cached_bandwidth(&mut hw);
|
||||
|
||||
11
crates/mesh-llm-commands/src/lib.rs
Normal file
11
crates/mesh-llm-commands/src/lib.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod agent_cli;
|
||||
pub mod auth;
|
||||
pub mod benchmark;
|
||||
pub mod gpus;
|
||||
pub mod model_package;
|
||||
pub mod plugin;
|
||||
pub mod runtime_native;
|
||||
pub mod skills;
|
||||
pub mod update;
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use model_package::jobs::HfJobsClient;
|
||||
use model_package::permissions;
|
||||
use model_package::prepare::{self, DiscoveredQuant, PrepareParams};
|
||||
use model_package::script;
|
||||
use ::model_package::jobs::HfJobsClient;
|
||||
use ::model_package::permissions;
|
||||
use ::model_package::prepare::{self, DiscoveredQuant, PrepareParams};
|
||||
use ::model_package::script;
|
||||
use serde_json::json;
|
||||
|
||||
/// All CLI arguments for `model-package`, bundled to avoid too-many-arguments.
|
||||
pub(crate) struct ModelPrepareArgs<'a> {
|
||||
pub struct ModelPrepareArgs<'a> {
|
||||
pub source_repo: Option<&'a str>,
|
||||
pub quant: Option<&'a str>,
|
||||
pub target: Option<&'a str>,
|
||||
|
|
@ -28,7 +28,7 @@ pub(crate) struct ModelPrepareArgs<'a> {
|
|||
}
|
||||
|
||||
/// Dispatch the model-package command.
|
||||
pub(crate) async fn dispatch_model_package(args: ModelPrepareArgs<'_>) -> Result<()> {
|
||||
pub async fn dispatch_model_package(args: ModelPrepareArgs<'_>) -> Result<()> {
|
||||
let ModelPrepareArgs {
|
||||
source_repo,
|
||||
quant,
|
||||
|
|
@ -90,7 +90,7 @@ pub(crate) async fn dispatch_model_package(args: ModelPrepareArgs<'_>) -> Result
|
|||
};
|
||||
|
||||
// Build HF client for API calls.
|
||||
let hf_client = model_package::build_hf_client()?;
|
||||
let hf_client = ::model_package::build_hf_client()?;
|
||||
|
||||
// If no quant specified, list available quants and exit.
|
||||
// This path doesn't need HF_TOKEN — works for public repos.
|
||||
|
|
@ -309,7 +309,7 @@ fn print_quant_table(quants: &[DiscoveredQuant]) {
|
|||
|
||||
async fn run_update_script() -> Result<()> {
|
||||
eprintln!("📤 Uploading embedded script to meshllm/layer-split-output bucket...");
|
||||
let client = model_package::build_hf_client()?;
|
||||
let client = ::model_package::build_hf_client()?;
|
||||
|
||||
// Check permissions first.
|
||||
let perms = permissions::check_permissions(&client).await?;
|
||||
|
|
@ -354,7 +354,7 @@ async fn run_status(client: &HfJobsClient, job_id: &str, json_output: bool) -> R
|
|||
}
|
||||
|
||||
async fn run_logs(client: &HfJobsClient, job_id: &str, json_output: bool) -> Result<()> {
|
||||
use model_package::jobs::JobStage;
|
||||
use ::model_package::jobs::JobStage;
|
||||
|
||||
let (namespace, id) = parse_job_id(job_id).await?;
|
||||
|
||||
|
|
@ -403,7 +403,7 @@ async fn run_cancel(client: &HfJobsClient, job_id: &str, json_output: bool) -> R
|
|||
|
||||
async fn run_list(client: &HfJobsClient, json_output: bool) -> Result<()> {
|
||||
// We need to know the namespace — resolve via whoami.
|
||||
let hf_client = model_package::build_hf_client()?;
|
||||
let hf_client = ::model_package::build_hf_client()?;
|
||||
let perms = permissions::check_permissions(&hf_client).await?;
|
||||
|
||||
let jobs = client.list(&perms.namespace).await?;
|
||||
|
|
@ -433,7 +433,7 @@ async fn run_list(client: &HfJobsClient, json_output: bool) -> Result<()> {
|
|||
|
||||
/// Follow job logs until the job reaches a terminal state.
|
||||
async fn follow_until_done(client: &HfJobsClient, namespace: &str, job_id: &str) -> Result<()> {
|
||||
use model_package::jobs::JobStage;
|
||||
use ::model_package::jobs::JobStage;
|
||||
|
||||
loop {
|
||||
loop {
|
||||
|
|
@ -523,7 +523,7 @@ async fn ensure_bucket_script_current(client: &hf_hub::HFClient) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
fn redacted_spec(spec: &model_package::jobs::JobSpec) -> model_package::jobs::JobSpec {
|
||||
fn redacted_spec(spec: &::model_package::jobs::JobSpec) -> ::model_package::jobs::JobSpec {
|
||||
let mut redacted = spec.clone();
|
||||
for value in redacted.secrets.values_mut() {
|
||||
if value.len() > 8 {
|
||||
|
|
@ -557,7 +557,7 @@ async fn parse_job_id(job_id: &str) -> Result<(String, String)> {
|
|||
Ok((ns.to_string(), id.to_string()))
|
||||
} else {
|
||||
// Need to figure out namespace from the user's identity.
|
||||
let hf_client = model_package::build_hf_client()?;
|
||||
let hf_client = ::model_package::build_hf_client()?;
|
||||
let perms = permissions::check_permissions(&hf_client).await?;
|
||||
Ok((perms.namespace, job_id.to_string()))
|
||||
}
|
||||
|
|
@ -7,11 +7,34 @@ use mesh_llm_plugin_manager::{
|
|||
};
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::cli::terminal_progress::{SpinnerHandle, clear_stderr_line, start_spinner};
|
||||
use crate::cli::{Cli, PluginCommand};
|
||||
use crate::runtime;
|
||||
use mesh_llm_cli::PluginCommand;
|
||||
use mesh_llm_tui::terminal_progress::{SpinnerHandle, clear_stderr_line, start_spinner};
|
||||
|
||||
pub(crate) async fn run_plugin_command(command: &PluginCommand, cli: &Cli) -> Result<()> {
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct PluginListRows {
|
||||
pub externals: Vec<RuntimePluginRow>,
|
||||
pub inactive: Vec<InactivePluginRow>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RuntimePluginRow {
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct InactivePluginRow {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub status: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run_plugin_command(
|
||||
command: &PluginCommand,
|
||||
runtime_rows: Option<&PluginListRows>,
|
||||
) -> Result<bool> {
|
||||
match command {
|
||||
PluginCommand::Install { reference } => install(reference).await?,
|
||||
PluginCommand::Update { name } => update(name).await?,
|
||||
|
|
@ -20,9 +43,14 @@ pub(crate) async fn run_plugin_command(command: &PluginCommand, cli: &Cli) -> Re
|
|||
PluginCommand::Delete { name } => delete(name)?,
|
||||
PluginCommand::Info { name } => info(name)?,
|
||||
PluginCommand::Search { query } => search(query.as_deref()).await?,
|
||||
PluginCommand::List => list(cli)?,
|
||||
PluginCommand::List => {
|
||||
let Some(runtime_rows) = runtime_rows else {
|
||||
return Ok(false);
|
||||
};
|
||||
list(runtime_rows)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn install(reference: &str) -> Result<()> {
|
||||
|
|
@ -113,7 +141,7 @@ async fn search(query: Option<&str>) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn list(cli: &Cli) -> Result<()> {
|
||||
fn list(runtime_rows: &PluginListRows) -> Result<()> {
|
||||
let store = PluginStore::new(default_store_root()?);
|
||||
for metadata in store.list()? {
|
||||
let state = if metadata.enabled {
|
||||
|
|
@ -127,8 +155,7 @@ fn list(cli: &Cli) -> Result<()> {
|
|||
);
|
||||
}
|
||||
|
||||
let resolved = runtime::load_resolved_plugins(cli)?;
|
||||
for spec in resolved.externals {
|
||||
for spec in &runtime_rows.externals {
|
||||
println!(
|
||||
"{}\tkind=runtime\tcommand={}\targs={}",
|
||||
spec.name,
|
||||
|
|
@ -136,13 +163,13 @@ fn list(cli: &Cli) -> Result<()> {
|
|||
spec.args.join(" ")
|
||||
);
|
||||
}
|
||||
for summary in resolved.inactive {
|
||||
for summary in &runtime_rows.inactive {
|
||||
println!(
|
||||
"{}\tkind={}\tstate={}\terror={}",
|
||||
summary.name,
|
||||
summary.kind,
|
||||
summary.status,
|
||||
summary.error.unwrap_or_default()
|
||||
summary.error.clone().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
427
crates/mesh-llm-commands/src/runtime_native.rs
Normal file
427
crates/mesh-llm-commands/src/runtime_native.rs
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
use anyhow::Result;
|
||||
use mesh_llm_native_runtime::{
|
||||
HostRuntimeProfile, NativeRuntimePruneMode, NativeRuntimeResolver, RuntimeSelection,
|
||||
};
|
||||
use mesh_llm_runtime_install::{
|
||||
CURRENT_MESH_VERSION, NativeRuntimeDownloadProgressCallback, NativeRuntimeInstallOptions,
|
||||
NativeRuntimeInstallStatus, NativeRuntimeManifestOptions, host_runtime_profile,
|
||||
install_native_runtime, load_release_manifest, native_runtime_cache,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub async fn run_native_runtime_list(
|
||||
available: bool,
|
||||
manifest_path: Option<&Path>,
|
||||
bundle_dirs: &[PathBuf],
|
||||
cache_dir: Option<&Path>,
|
||||
json_output: bool,
|
||||
) -> Result<()> {
|
||||
let cache = native_runtime_cache(cache_dir)?;
|
||||
if available {
|
||||
if !json_output && manifest_path.is_none() && bundle_dirs.is_empty() {
|
||||
eprintln!("🔎 Loading native runtime release manifest");
|
||||
}
|
||||
let manifest = load_release_manifest(NativeRuntimeManifestOptions {
|
||||
manifest_path: manifest_path.map(Path::to_path_buf),
|
||||
bundle_dirs: bundle_dirs.to_vec(),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let profile = host_runtime_profile();
|
||||
let cache = native_runtime_cache(cache_dir)?;
|
||||
let resolution = NativeRuntimeResolver::new(
|
||||
CURRENT_MESH_VERSION,
|
||||
profile.clone(),
|
||||
manifest.clone(),
|
||||
cache,
|
||||
)
|
||||
.resolve(&RuntimeSelection::Recommended)
|
||||
.ok();
|
||||
let rows = manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.map(|artifact| {
|
||||
let evaluation = resolution.as_ref().and_then(|resolution| {
|
||||
resolution
|
||||
.evaluated
|
||||
.iter()
|
||||
.find(|candidate| candidate.artifact.id == artifact.id)
|
||||
});
|
||||
let supported = evaluation.is_some_and(|candidate| candidate.compatible);
|
||||
json!({
|
||||
"id": artifact.id,
|
||||
"mesh_version": artifact.mesh_version.as_deref(),
|
||||
"skippy_abi": artifact.skippy_abi,
|
||||
"backend": artifact.backend.kind.to_string(),
|
||||
"os": artifact.platform.os,
|
||||
"arch": artifact.platform.arch,
|
||||
"supported": supported,
|
||||
"rejection_reasons": evaluation.map(|candidate| &candidate.rejection_reasons),
|
||||
"url": artifact.url.as_deref(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if json_output {
|
||||
println!("{}", serde_json::to_string_pretty(&rows)?);
|
||||
} else {
|
||||
print_available_runtimes(&rows);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let installed = cache.installed()?;
|
||||
if json_output {
|
||||
println!("{}", serde_json::to_string_pretty(&installed)?);
|
||||
} else {
|
||||
print_installed_runtimes(&installed, cache.root());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_native_runtime_install(
|
||||
requested_runtime: Option<&str>,
|
||||
manifest_path: Option<&Path>,
|
||||
bundle_dirs: &[PathBuf],
|
||||
cache_dir: Option<&Path>,
|
||||
json_output: bool,
|
||||
) -> Result<()> {
|
||||
let selection = RuntimeSelection::parse(requested_runtime)?;
|
||||
if !json_output && manifest_path.is_none() && bundle_dirs.is_empty() {
|
||||
eprintln!("🔎 Loading native runtime release manifest");
|
||||
}
|
||||
if !json_output {
|
||||
eprintln!("🔎 Detecting host runtime profile");
|
||||
}
|
||||
let outcome = install_native_runtime(NativeRuntimeInstallOptions {
|
||||
selection,
|
||||
manifest_path: manifest_path.map(Path::to_path_buf),
|
||||
bundle_dirs: bundle_dirs.to_vec(),
|
||||
cache_dir: cache_dir.map(Path::to_path_buf),
|
||||
progress: cli_download_progress(json_output),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
match outcome.status {
|
||||
NativeRuntimeInstallStatus::AlreadyInstalled => {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"status": "already_installed",
|
||||
"runtime": outcome.runtime,
|
||||
"resolution": outcome.resolution,
|
||||
}))?
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"✅ Native runtime already installed: {}",
|
||||
outcome.runtime.native_runtime_id
|
||||
);
|
||||
eprintln!(" path: {}", outcome.runtime.path.display());
|
||||
}
|
||||
}
|
||||
NativeRuntimeInstallStatus::Installed => {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"status": "installed",
|
||||
"runtime": outcome.runtime,
|
||||
"resolution": outcome.resolution,
|
||||
}))?
|
||||
);
|
||||
} else {
|
||||
eprintln!("✅ Installed {}", outcome.runtime.native_runtime_id);
|
||||
eprintln!(" version: {}", outcome.runtime.mesh_version);
|
||||
eprintln!(" flavor: {}", outcome.runtime.flavor);
|
||||
eprintln!(" path: {}", outcome.runtime.path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct DownloadProgress {
|
||||
native_runtime_id: Option<String>,
|
||||
last_percent: Option<u64>,
|
||||
last_tick: Instant,
|
||||
}
|
||||
|
||||
impl DownloadProgress {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
native_runtime_id: None,
|
||||
last_percent: None,
|
||||
last_tick: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(
|
||||
&mut self,
|
||||
native_runtime_id: &str,
|
||||
downloaded: u64,
|
||||
total: Option<u64>,
|
||||
finished: bool,
|
||||
) {
|
||||
if self.native_runtime_id.is_none() {
|
||||
self.native_runtime_id = Some(native_runtime_id.to_string());
|
||||
eprintln!("⬇️ Downloading native runtime {native_runtime_id}");
|
||||
}
|
||||
if finished {
|
||||
self.finish(downloaded);
|
||||
return;
|
||||
}
|
||||
let should_print = match total {
|
||||
Some(total) if total > 0 => {
|
||||
let percent = downloaded.saturating_mul(100) / total;
|
||||
let crossed_step = self
|
||||
.last_percent
|
||||
.map(|last| percent >= last.saturating_add(5))
|
||||
.unwrap_or(true);
|
||||
if crossed_step || percent == 100 {
|
||||
self.last_percent = Some(percent);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => self.last_tick.elapsed() >= Duration::from_secs(1),
|
||||
};
|
||||
if should_print {
|
||||
self.last_tick = Instant::now();
|
||||
match total {
|
||||
Some(total) if total > 0 => eprintln!(
|
||||
" downloaded {} / {} ({})",
|
||||
human_bytes(downloaded),
|
||||
human_bytes(total),
|
||||
format_args!("{}%", self.last_percent.unwrap_or(0))
|
||||
),
|
||||
_ => eprintln!(" downloaded {}", human_bytes(downloaded)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, downloaded: u64) {
|
||||
eprintln!(" downloaded {}", human_bytes(downloaded));
|
||||
}
|
||||
}
|
||||
|
||||
fn cli_download_progress(json_output: bool) -> Option<NativeRuntimeDownloadProgressCallback> {
|
||||
if json_output {
|
||||
return None;
|
||||
}
|
||||
let progress = Arc::new(Mutex::new(DownloadProgress::new()));
|
||||
Some(Arc::new(move |event| {
|
||||
let Ok(mut progress) = progress.lock() else {
|
||||
return;
|
||||
};
|
||||
progress.tick(
|
||||
&event.native_runtime_id,
|
||||
event.downloaded_bytes,
|
||||
event.total_bytes,
|
||||
event.finished,
|
||||
);
|
||||
}))
|
||||
}
|
||||
|
||||
fn human_bytes(bytes: u64) -> String {
|
||||
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = UNITS[0];
|
||||
for candidate in UNITS.iter().skip(1) {
|
||||
if value < 1024.0 {
|
||||
break;
|
||||
}
|
||||
value /= 1024.0;
|
||||
unit = candidate;
|
||||
}
|
||||
if unit == "B" {
|
||||
format!("{bytes} {unit}")
|
||||
} else {
|
||||
format!("{value:.1} {unit}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_native_runtime_remove(
|
||||
native_runtime_id: &str,
|
||||
mesh_version: Option<&str>,
|
||||
cache_dir: Option<&Path>,
|
||||
json_output: bool,
|
||||
) -> Result<()> {
|
||||
let version = mesh_version.unwrap_or(CURRENT_MESH_VERSION);
|
||||
let cache = native_runtime_cache(cache_dir)?;
|
||||
let removed = cache.remove(version, native_runtime_id)?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"mesh_version": version,
|
||||
"native_runtime_id": native_runtime_id,
|
||||
"removed": removed,
|
||||
}))?
|
||||
);
|
||||
} else if removed {
|
||||
eprintln!("✅ Removed native runtime {native_runtime_id} for MeshLLM {version}");
|
||||
} else {
|
||||
eprintln!("🔎 Native runtime {native_runtime_id} for MeshLLM {version} was not installed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_native_runtime_prune(
|
||||
active_only: bool,
|
||||
mesh_version: Option<&str>,
|
||||
cache_dir: Option<&Path>,
|
||||
json_output: bool,
|
||||
) -> Result<()> {
|
||||
let version = mesh_version.unwrap_or(CURRENT_MESH_VERSION);
|
||||
let mode = if active_only {
|
||||
NativeRuntimePruneMode::ActiveOnly
|
||||
} else {
|
||||
NativeRuntimePruneMode::KeepActiveAndPrevious
|
||||
};
|
||||
let cache = native_runtime_cache(cache_dir)?;
|
||||
let plan = cache.prune(version, mode)?;
|
||||
if json_output {
|
||||
println!("{}", serde_json::to_string_pretty(&plan)?);
|
||||
} else if plan.remove_dirs.is_empty() {
|
||||
eprintln!("✅ Native runtime cache already pruned");
|
||||
} else {
|
||||
eprintln!(
|
||||
"✅ Pruned {} native runtime cache version(s)",
|
||||
plan.remove_dirs.len()
|
||||
);
|
||||
for dir in plan.remove_dirs {
|
||||
eprintln!(" removed: {}", dir.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_native_runtime_doctor(json_output: bool) -> Result<()> {
|
||||
let cache = native_runtime_cache(None)?;
|
||||
let profile = host_runtime_profile();
|
||||
let installed = cache.installed()?;
|
||||
let current_version_runtimes = installed
|
||||
.iter()
|
||||
.filter(|runtime| runtime.mesh_version == CURRENT_MESH_VERSION)
|
||||
.collect::<Vec<_>>();
|
||||
let selected = current_version_runtimes
|
||||
.iter()
|
||||
.max_by_key(|runtime| runtime.manifest.runtime.backend.kind.default_rank());
|
||||
|
||||
let report = NativeRuntimeDoctorReport {
|
||||
mesh_version: CURRENT_MESH_VERSION.to_string(),
|
||||
host: profile,
|
||||
cache_path: cache.root().to_path_buf(),
|
||||
selected_runtime_id: selected.map(|runtime| runtime.native_runtime_id.clone()),
|
||||
selected_runtime_flavor: selected.map(|runtime| runtime.flavor.clone()),
|
||||
selected_runtime_path: selected.map(|runtime| runtime.path.clone()),
|
||||
installed_count: installed.len(),
|
||||
current_version_installed_count: current_version_runtimes.len(),
|
||||
};
|
||||
|
||||
if json_output {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
print_doctor_report(&report);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct NativeRuntimeDoctorReport {
|
||||
mesh_version: String,
|
||||
host: HostRuntimeProfile,
|
||||
cache_path: PathBuf,
|
||||
selected_runtime_id: Option<String>,
|
||||
selected_runtime_flavor: Option<String>,
|
||||
selected_runtime_path: Option<PathBuf>,
|
||||
installed_count: usize,
|
||||
current_version_installed_count: usize,
|
||||
}
|
||||
|
||||
fn print_available_runtimes(rows: &[serde_json::Value]) {
|
||||
if rows.is_empty() {
|
||||
println!("📦 No native runtime manifest entries found");
|
||||
println!(" Pass --manifest or --bundle-dir to inspect available runtimes.");
|
||||
return;
|
||||
}
|
||||
println!("📦 Available native runtimes");
|
||||
for row in rows {
|
||||
let status = if row["supported"].as_bool().unwrap_or(false) {
|
||||
"compatible"
|
||||
} else {
|
||||
"not compatible"
|
||||
};
|
||||
println!(
|
||||
" - {} {} ({}, {}/{})",
|
||||
row["id"].as_str().unwrap_or("unknown"),
|
||||
status,
|
||||
row["backend"].as_str().unwrap_or("unknown"),
|
||||
row["os"].as_str().unwrap_or("unknown"),
|
||||
row["arch"].as_str().unwrap_or("unknown")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_installed_runtimes(
|
||||
installed: &[mesh_llm_native_runtime::InstalledNativeRuntime],
|
||||
cache_root: &Path,
|
||||
) {
|
||||
if installed.is_empty() {
|
||||
println!("📦 No native runtimes installed");
|
||||
println!(" cache: {}", cache_root.display());
|
||||
return;
|
||||
}
|
||||
println!("📦 Installed native runtimes");
|
||||
println!(" cache: {}", cache_root.display());
|
||||
for runtime in installed {
|
||||
println!(
|
||||
" - {} {} ({})",
|
||||
runtime.native_runtime_id, runtime.mesh_version, runtime.flavor
|
||||
);
|
||||
println!(" path: {}", runtime.path.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn print_doctor_report(report: &NativeRuntimeDoctorReport) {
|
||||
println!("🩺 MeshLLM doctor");
|
||||
println!();
|
||||
println!("Native runtime:");
|
||||
println!(" mesh version: {}", report.mesh_version);
|
||||
println!(" cache: {}", report.cache_path.display());
|
||||
println!(" host: {}/{}", report.host.os, report.host.arch);
|
||||
let flavors = report
|
||||
.host
|
||||
.available_flavors
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
println!(" detected flavors: {flavors}");
|
||||
match &report.selected_runtime_id {
|
||||
Some(id) => {
|
||||
println!(" selected: {id}");
|
||||
if let Some(flavor) = &report.selected_runtime_flavor {
|
||||
println!(" flavor: {flavor}");
|
||||
}
|
||||
if let Some(path) = &report.selected_runtime_path {
|
||||
println!(" path: {}", path.display());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!(" selected: none");
|
||||
println!(" status: no native runtime installed for this MeshLLM version");
|
||||
}
|
||||
}
|
||||
println!(" installed: {}", report.installed_count);
|
||||
println!(
|
||||
" installed for current version: {}",
|
||||
report.current_version_installed_count
|
||||
);
|
||||
}
|
||||
|
|
@ -4,9 +4,10 @@ use mesh_llm_plugin_manager::{
|
|||
install_available_skills,
|
||||
};
|
||||
|
||||
use crate::cli::{SkillAgentArg, SkillCommand, output::json_mode_enabled};
|
||||
use mesh_llm_cli::{SkillAgentArg, SkillCommand};
|
||||
use mesh_llm_tui::json_mode_enabled;
|
||||
|
||||
pub(crate) fn run_skills_command(command: &SkillCommand) -> Result<()> {
|
||||
pub fn run_skills_command(command: &SkillCommand) -> Result<()> {
|
||||
match command {
|
||||
SkillCommand::Install {
|
||||
agent,
|
||||
|
|
@ -17,7 +18,7 @@ pub(crate) fn run_skills_command(command: &SkillCommand) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn install_skills_for_agent(agent: SkillAgent) {
|
||||
pub fn install_skills_for_agent(agent: SkillAgent) {
|
||||
match PluginSkillInstallOptions::for_agent(agent).and_then(|options| {
|
||||
let report = install_available_skills(&options)?;
|
||||
Ok(report)
|
||||
|
|
@ -41,7 +42,11 @@ fn install(agents: &[SkillAgentArg], all: bool, dry_run: bool, force: bool) -> R
|
|||
options.skill_options.detected_only = false;
|
||||
}
|
||||
if !agents.is_empty() {
|
||||
options.skill_options.agents = agents.iter().copied().map(Into::into).collect();
|
||||
options.skill_options.agents = agents
|
||||
.iter()
|
||||
.copied()
|
||||
.map(skill_agent_arg_to_manager)
|
||||
.collect();
|
||||
options.skill_options.detected_only = false;
|
||||
}
|
||||
let report = install_available_skills(&options)?;
|
||||
|
|
@ -49,6 +54,17 @@ fn install(agents: &[SkillAgentArg], all: bool, dry_run: bool, force: bool) -> R
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn skill_agent_arg_to_manager(agent: SkillAgentArg) -> mesh_llm_plugin_manager::SkillAgent {
|
||||
match agent {
|
||||
SkillAgentArg::Global => mesh_llm_plugin_manager::SkillAgent::Global,
|
||||
SkillAgentArg::Goose => mesh_llm_plugin_manager::SkillAgent::Goose,
|
||||
SkillAgentArg::Pi => mesh_llm_plugin_manager::SkillAgent::Pi,
|
||||
SkillAgentArg::Codex => mesh_llm_plugin_manager::SkillAgent::Codex,
|
||||
SkillAgentArg::Opencode => mesh_llm_plugin_manager::SkillAgent::Opencode,
|
||||
SkillAgentArg::Claude => mesh_llm_plugin_manager::SkillAgent::Claude,
|
||||
}
|
||||
}
|
||||
|
||||
fn print_agent_install_summary(agent: SkillAgent, report: &SkillInstallReport) {
|
||||
if json_mode_enabled() {
|
||||
return;
|
||||
35
crates/mesh-llm-commands/src/update.rs
Normal file
35
crates/mesh-llm-commands/src/update.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use anyhow::Result;
|
||||
use mesh_llm_cli::{BinaryFlavor, Cli, Command};
|
||||
use mesh_llm_system::{autoupdate, backend};
|
||||
|
||||
pub async fn run_update(cli: &Cli) -> Result<()> {
|
||||
let (requested_version, flavor, detect_flavor) = match &cli.command {
|
||||
Some(Command::Update {
|
||||
version,
|
||||
flavor,
|
||||
detect_flavor,
|
||||
}) => (
|
||||
version.as_deref(),
|
||||
binary_flavor_to_backend(*flavor),
|
||||
*detect_flavor,
|
||||
),
|
||||
_ => (None, None, false),
|
||||
};
|
||||
autoupdate::run_update_command(autoupdate::UpdateCommandOptions {
|
||||
flavor,
|
||||
detect_flavor,
|
||||
requested_version,
|
||||
current_version: env!("CARGO_PKG_VERSION"),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn binary_flavor_to_backend(flavor: Option<BinaryFlavor>) -> Option<backend::BinaryFlavor> {
|
||||
flavor.map(|flavor| match flavor {
|
||||
BinaryFlavor::Cpu => backend::BinaryFlavor::Cpu,
|
||||
BinaryFlavor::Cuda => backend::BinaryFlavor::Cuda,
|
||||
BinaryFlavor::Rocm => backend::BinaryFlavor::Rocm,
|
||||
BinaryFlavor::Vulkan => backend::BinaryFlavor::Vulkan,
|
||||
BinaryFlavor::Metal => backend::BinaryFlavor::Metal,
|
||||
})
|
||||
}
|
||||
|
|
@ -2,16 +2,20 @@
|
|||
name = "mesh-llm-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Configuration parsing and validation for mesh-llm"
|
||||
repository = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
homepage = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
mesh-llm-types = { path = "../mesh-llm-types" }
|
||||
mesh-llm-types = { path = "../mesh-llm-types", version = "0.68.0" }
|
||||
semver = "1"
|
||||
serde = { workspace = true }
|
||||
skippy-protocol = { path = "../skippy-protocol" }
|
||||
skippy-protocol = { path = "../skippy-protocol", version = "0.68.0" }
|
||||
toml = "0.9"
|
||||
toml_edit = "0.25"
|
||||
dirs = "6.0.0"
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@
|
|||
name = "mesh-llm-console-server"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
description = "Static file server for embedded Mesh LLM console assets"
|
||||
license.workspace = true
|
||||
repository = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
homepage = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
readme = "README.md"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
mesh-llm-ui = { path = "../mesh-llm-ui", default-features = false }
|
||||
mesh-llm-ui = { path = "../mesh-llm-ui", version = "0.68.0", default-features = false }
|
||||
tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "sync", "time"] }
|
||||
|
|
|
|||
25
crates/mesh-llm-embedded-runtime/Cargo.toml
Normal file
25
crates/mesh-llm-embedded-runtime/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "mesh-llm-embedded-runtime"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
version.workspace = true
|
||||
description = "In-process full Mesh LLM node embedding API"
|
||||
repository = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
homepage = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
readme = "README.md"
|
||||
keywords = ["llm", "mesh", "runtime", "embedding"]
|
||||
categories = ["api-bindings", "network-programming"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
web-ui = ["mesh-llm-host-runtime/web-ui"]
|
||||
dynamic-native-runtime = ["mesh-llm-host-runtime/dynamic-native-runtime"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
mesh-llm-host-runtime = { path = "../mesh-llm-host-runtime", version = "0.68.0", default-features = false }
|
||||
serde_json.workspace = true
|
||||
|
||||
36
crates/mesh-llm-embedded-runtime/README.md
Normal file
36
crates/mesh-llm-embedded-runtime/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# mesh-llm-embedded-runtime
|
||||
|
||||
`mesh-llm-embedded-runtime` exposes the in-process full Mesh LLM node API for
|
||||
applications that want a local OpenAI-compatible `/v1` endpoint without
|
||||
spawning the `mesh-llm` CLI as a sidecar.
|
||||
|
||||
This crate is intentionally separate from the default `mesh-llm-sdk` facade so
|
||||
client-only consumers do not pull in the full host runtime graph.
|
||||
|
||||
## Example
|
||||
|
||||
```rust,no_run
|
||||
use mesh_llm_embedded_runtime::{
|
||||
EmbeddedMeshNodeConfig, EmbeddedMeshNodeMode, start_embedded_node,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let node = start_embedded_node(
|
||||
EmbeddedMeshNodeConfig::builder()
|
||||
.mode(EmbeddedMeshNodeMode::Serve)
|
||||
.model("unsloth/Qwen3-0.6B-GGUF:Q4_K_M")
|
||||
.api_port(9337)
|
||||
.console_port(3131)
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("OpenAI API: {}", node.api_base_url());
|
||||
println!("console: {}", node.console_url());
|
||||
|
||||
node.stop().await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
19
crates/mesh-llm-embedded-runtime/src/lib.rs
Normal file
19
crates/mesh-llm-embedded-runtime/src/lib.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#![forbid(unsafe_code)]
|
||||
|
||||
pub use mesh_llm_host_runtime::sdk::{
|
||||
EmbeddedChatMessage, EmbeddedMeshAdmissionConfig, EmbeddedMeshDiscoveryMode,
|
||||
EmbeddedMeshHttpConfig, EmbeddedMeshLogFormat, EmbeddedMeshNetworkConfig,
|
||||
EmbeddedMeshNodeBuilder, EmbeddedMeshNodeConfig, EmbeddedMeshNodeHandle, EmbeddedMeshNodeMode,
|
||||
EmbeddedMeshNodeStatus, EmbeddedMeshRequirementsConfig, EmbeddedMeshServingConfig,
|
||||
EmbeddedMeshStorageConfig, EmbeddedServeConfig, EmbeddedServeHandle, EmbeddedServeMode,
|
||||
EmbeddedServeStatus, EmbeddedServingController, EmbeddedTrustPolicy,
|
||||
SIGNED_JOIN_TOKEN_MIN_PROTOCOL_VERSION, start_embedded_node, start_embedded_serve,
|
||||
};
|
||||
|
||||
pub mod config {
|
||||
pub use mesh_llm_host_runtime::sdk::config::*;
|
||||
}
|
||||
|
||||
pub mod native_runtime {
|
||||
pub use mesh_llm_host_runtime::sdk::native_runtime::*;
|
||||
}
|
||||
19
crates/mesh-llm-events/Cargo.toml
Normal file
19
crates/mesh-llm-events/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "mesh-llm-events"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
version.workspace = true
|
||||
description = "Shared runtime event and output contracts for mesh-llm"
|
||||
repository = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
homepage = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
readme = "README.md"
|
||||
keywords = ["llm", "mesh", "events"]
|
||||
categories = ["command-line-interface"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde_json.workspace = true
|
||||
21
crates/mesh-llm-events/README.md
Normal file
21
crates/mesh-llm-events/README.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# mesh-llm-events
|
||||
|
||||
`mesh-llm-events` owns the typed event contract shared by the mesh runtime,
|
||||
CLI, SDK-facing embedded runtime, and terminal UI.
|
||||
|
||||
The crate intentionally does not render anything. It defines the structured
|
||||
values that runtime code can emit and that presentation layers such as
|
||||
`mesh-llm-tui` can render as pretty terminal output, TUI dashboard state, or
|
||||
JSONL records.
|
||||
|
||||
## API Shape
|
||||
|
||||
- `LogFormat` selects pretty terminal output or JSONL.
|
||||
- `OutputEvent` is the structured runtime event taxonomy.
|
||||
- `RuntimeStatus`, `DashboardSnapshot`, and related dashboard row types are the
|
||||
shared status model consumed by the TUI.
|
||||
- `DashboardSnapshotProvider` lets runtime code provide periodic dashboard
|
||||
snapshots without depending on a renderer.
|
||||
|
||||
Rendering, progress bars, alternate-screen handling, and terminal control stay
|
||||
in `mesh-llm-tui`.
|
||||
876
crates/mesh-llm-events/src/lib.rs
Normal file
876
crates/mesh-llm-events/src/lib.rs
Normal file
|
|
@ -0,0 +1,876 @@
|
|||
#![forbid(unsafe_code)]
|
||||
|
||||
use clap::ValueEnum;
|
||||
use serde_json::Value;
|
||||
use std::future::Future;
|
||||
use std::io::{self, IsTerminal};
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, OnceLock, RwLock};
|
||||
|
||||
pub mod terminal_progress;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
|
||||
pub enum LogFormat {
|
||||
#[default]
|
||||
Pretty,
|
||||
Json,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum RuntimeStatus {
|
||||
NotReady,
|
||||
Starting,
|
||||
Loading,
|
||||
Ready,
|
||||
ShuttingDown,
|
||||
Stopped,
|
||||
Exited,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl RuntimeStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
RuntimeStatus::NotReady => "NOT READY",
|
||||
RuntimeStatus::Starting => "starting",
|
||||
RuntimeStatus::Loading => "loading",
|
||||
RuntimeStatus::Ready => "ready",
|
||||
RuntimeStatus::ShuttingDown => "shutting down",
|
||||
RuntimeStatus::Stopped => "stopped",
|
||||
RuntimeStatus::Exited => "exited",
|
||||
RuntimeStatus::Warning => "warning",
|
||||
RuntimeStatus::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ConsoleSessionMode {
|
||||
InteractiveDashboard,
|
||||
Fallback,
|
||||
None,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct DashboardProcessRow {
|
||||
pub name: String,
|
||||
pub backend: String,
|
||||
pub status: RuntimeStatus,
|
||||
pub port: u16,
|
||||
pub pid: u32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct DashboardEndpointRow {
|
||||
pub label: String,
|
||||
pub status: RuntimeStatus,
|
||||
pub url: String,
|
||||
pub port: u16,
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DashboardModelRow {
|
||||
pub name: String,
|
||||
pub role: Option<String>,
|
||||
pub status: RuntimeStatus,
|
||||
pub port: Option<u16>,
|
||||
pub device: Option<String>,
|
||||
pub slots: Option<usize>,
|
||||
pub quantization: Option<String>,
|
||||
pub ctx_size: Option<u32>,
|
||||
pub ctx_used_tokens: Option<u64>,
|
||||
pub lanes: Option<Vec<DashboardModelLane>>,
|
||||
pub file_size_gb: Option<f64>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct DashboardModelLane {
|
||||
pub index: usize,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct DashboardAcceptedRequestBucket {
|
||||
pub second_offset: u32,
|
||||
pub accepted_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ModelProgressStatus {
|
||||
Ensuring,
|
||||
Downloading,
|
||||
Ready,
|
||||
}
|
||||
|
||||
impl ModelProgressStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ModelProgressStatus::Ensuring => "ensuring",
|
||||
ModelProgressStatus::Downloading => "downloading",
|
||||
ModelProgressStatus::Ready => "ready",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DashboardSnapshot {
|
||||
pub llama_process_rows: Vec<DashboardProcessRow>,
|
||||
pub webserver_rows: Vec<DashboardEndpointRow>,
|
||||
pub loaded_model_rows: Vec<DashboardModelRow>,
|
||||
pub current_inflight_requests: u64,
|
||||
pub accepted_request_buckets: Vec<DashboardAcceptedRequestBucket>,
|
||||
pub latency_samples_ms: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Default for DashboardSnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
llama_process_rows: Vec::new(),
|
||||
webserver_rows: Vec::new(),
|
||||
loaded_model_rows: Vec::new(),
|
||||
current_inflight_requests: 0,
|
||||
accepted_request_buckets: (0..30)
|
||||
.map(|second_offset| DashboardAcceptedRequestBucket {
|
||||
second_offset,
|
||||
accepted_count: 0,
|
||||
})
|
||||
.collect(),
|
||||
latency_samples_ms: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct DashboardLaunchPlan {
|
||||
pub llama_process_rows: Vec<DashboardProcessRow>,
|
||||
pub webserver_rows: Vec<DashboardEndpointRow>,
|
||||
pub loaded_model_rows: Vec<DashboardModelRow>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub type DashboardSnapshotFuture<'a> = Pin<Box<dyn Future<Output = DashboardSnapshot> + Send + 'a>>;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub trait DashboardSnapshotProvider: Send + Sync {
|
||||
fn snapshot(&self) -> DashboardSnapshotFuture<'_>;
|
||||
}
|
||||
|
||||
pub type OutputSinkFuture<'a, T> = Pin<Box<dyn Future<Output = io::Result<T>> + Send + 'a>>;
|
||||
|
||||
pub trait OutputSink: Send + Sync {
|
||||
fn emit_event(&self, event: OutputEvent) -> io::Result<()>;
|
||||
|
||||
fn schedule_ready_prompt(&self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_ready_prompt(&self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ready_prompt_active(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn flush(&self) -> OutputSinkFuture<'_, ()> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn mode(&self) -> LogFormat {
|
||||
LogFormat::Pretty
|
||||
}
|
||||
|
||||
fn console_session_mode(&self) -> Option<ConsoleSessionMode> {
|
||||
None
|
||||
}
|
||||
|
||||
fn register_dashboard_snapshot_provider(&self, _provider: Arc<dyn DashboardSnapshotProvider>) {}
|
||||
|
||||
fn enter_tui(&self) -> OutputSinkFuture<'_, ()> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn exit_tui(&self) -> OutputSinkFuture<'_, ()> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn dispatch_tui_event(&self, _event: TuiEvent) -> OutputSinkFuture<'_, TuiControlFlow> {
|
||||
Box::pin(async { Ok(TuiControlFlow::Continue) })
|
||||
}
|
||||
|
||||
fn render_tui_if_dirty(&self) -> OutputSinkFuture<'_, bool> {
|
||||
Box::pin(async { Ok(false) })
|
||||
}
|
||||
|
||||
fn force_restore_tui_terminal(&self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
static OUTPUT_SINK: OnceLock<RwLock<Option<Arc<dyn OutputSink>>>> = OnceLock::new();
|
||||
|
||||
fn output_sink_slot() -> &'static RwLock<Option<Arc<dyn OutputSink>>> {
|
||||
OUTPUT_SINK.get_or_init(|| RwLock::new(None))
|
||||
}
|
||||
|
||||
pub fn set_output_sink(sink: Arc<dyn OutputSink>) {
|
||||
if let Ok(mut slot) = output_sink_slot().write() {
|
||||
*slot = Some(sink);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_output_sink() {
|
||||
if let Ok(mut slot) = output_sink_slot().write() {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn output_sink() -> Option<Arc<dyn OutputSink>> {
|
||||
output_sink_slot()
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|slot| slot.as_ref().cloned())
|
||||
}
|
||||
|
||||
pub fn emit_event(event: OutputEvent) -> io::Result<()> {
|
||||
match output_sink() {
|
||||
Some(sink) => sink.emit_event(event),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn flush_output() -> io::Result<()> {
|
||||
match output_sink() {
|
||||
Some(sink) => sink.flush().await,
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schedule_ready_prompt() -> io::Result<()> {
|
||||
match output_sink() {
|
||||
Some(sink) => sink.schedule_ready_prompt(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn json_mode_enabled() -> bool {
|
||||
output_sink().is_some_and(|sink| matches!(sink.mode(), LogFormat::Json))
|
||||
}
|
||||
|
||||
pub fn interactive_tui_active() -> bool {
|
||||
output_sink().is_some_and(|sink| {
|
||||
matches!(sink.mode(), LogFormat::Pretty)
|
||||
&& matches!(
|
||||
sink.console_session_mode(),
|
||||
Some(ConsoleSessionMode::InteractiveDashboard)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn current_console_session_mode() -> ConsoleSessionMode {
|
||||
console_session_mode(
|
||||
std::io::stdin().is_terminal(),
|
||||
std::io::stderr().is_terminal(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn console_session_mode(stdin_is_tty: bool, stderr_is_tty: bool) -> ConsoleSessionMode {
|
||||
console_session_mode_for_term(
|
||||
stdin_is_tty,
|
||||
stderr_is_tty,
|
||||
std::env::var("TERM").ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn console_session_mode_for_term(
|
||||
stdin_is_tty: bool,
|
||||
stderr_is_tty: bool,
|
||||
term: Option<&str>,
|
||||
) -> ConsoleSessionMode {
|
||||
if stdin_is_tty && stderr_is_tty && terminal_supports_dashboard(term) {
|
||||
ConsoleSessionMode::InteractiveDashboard
|
||||
} else {
|
||||
ConsoleSessionMode::Fallback
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_supports_dashboard(term: Option<&str>) -> bool {
|
||||
match term.map(str::trim).filter(|term| !term.is_empty()) {
|
||||
Some(term) => term != "dumb",
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_dashboard_endpoint_rows(rows: &mut [DashboardEndpointRow]) {
|
||||
rows.sort_by(|left, right| {
|
||||
dashboard_endpoint_sort_bucket(left)
|
||||
.cmp(&dashboard_endpoint_sort_bucket(right))
|
||||
.then_with(|| left.label.cmp(&right.label))
|
||||
});
|
||||
}
|
||||
|
||||
fn dashboard_endpoint_sort_bucket(row: &DashboardEndpointRow) -> u8 {
|
||||
if row.label.starts_with("Plugin: ") {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TuiKeyEvent {
|
||||
Tab,
|
||||
BackTab,
|
||||
Backspace,
|
||||
Enter,
|
||||
Escape,
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Interrupt,
|
||||
Char(char),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TuiEvent {
|
||||
Key(TuiKeyEvent),
|
||||
Resize { columns: u16, rows: u16 },
|
||||
MouseDown { column: u16, row: u16 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TuiControlFlow {
|
||||
Continue,
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum OutputLevel {
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl OutputLevel {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
OutputLevel::Debug => "debug",
|
||||
OutputLevel::Info => "info",
|
||||
OutputLevel::Warn => "warn",
|
||||
OutputLevel::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum LlamaInstanceKind {
|
||||
LlamaServer,
|
||||
}
|
||||
|
||||
impl LlamaInstanceKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
LlamaInstanceKind::LlamaServer => "llama-server",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_key(&self) -> u8 {
|
||||
match self {
|
||||
LlamaInstanceKind::LlamaServer => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum OutputEvent {
|
||||
Info {
|
||||
message: String,
|
||||
context: Option<String>,
|
||||
},
|
||||
Startup {
|
||||
version: String,
|
||||
message: Option<String>,
|
||||
},
|
||||
LaunchPlan {
|
||||
plan: DashboardLaunchPlan,
|
||||
},
|
||||
NodeIdentity {
|
||||
node_id: String,
|
||||
mesh_id: Option<String>,
|
||||
},
|
||||
InviteToken {
|
||||
token: String,
|
||||
mesh_id: String,
|
||||
mesh_name: Option<String>,
|
||||
},
|
||||
DiscoveryStarting {
|
||||
source: String,
|
||||
},
|
||||
MeshFound {
|
||||
mesh: String,
|
||||
peers: usize,
|
||||
region: Option<String>,
|
||||
},
|
||||
DiscoveryJoined {
|
||||
mesh: String,
|
||||
},
|
||||
DiscoveryFailed {
|
||||
message: String,
|
||||
detail: Option<String>,
|
||||
},
|
||||
WaitingForPeers {
|
||||
detail: Option<String>,
|
||||
},
|
||||
PassiveMode {
|
||||
role: String,
|
||||
status: RuntimeStatus,
|
||||
capacity_gb: Option<f64>,
|
||||
models_on_disk: Option<Vec<String>>,
|
||||
detail: Option<String>,
|
||||
},
|
||||
PeerJoined {
|
||||
peer_id: String,
|
||||
label: Option<String>,
|
||||
},
|
||||
PeerLeft {
|
||||
peer_id: String,
|
||||
reason: Option<String>,
|
||||
},
|
||||
ModelQueued {
|
||||
model: String,
|
||||
},
|
||||
ModelLoading {
|
||||
model: String,
|
||||
source: Option<String>,
|
||||
},
|
||||
ModelLoaded {
|
||||
model: String,
|
||||
bytes: Option<u64>,
|
||||
},
|
||||
ModelUnloading {
|
||||
model: String,
|
||||
},
|
||||
ModelUnloaded {
|
||||
model: String,
|
||||
},
|
||||
HostElected {
|
||||
model: String,
|
||||
host: String,
|
||||
role: Option<String>,
|
||||
capacity_gb: Option<f64>,
|
||||
},
|
||||
RpcServerStarting {
|
||||
port: u16,
|
||||
device: String,
|
||||
log_path: Option<String>,
|
||||
},
|
||||
RpcReady {
|
||||
port: u16,
|
||||
device: String,
|
||||
log_path: Option<String>,
|
||||
},
|
||||
RpcStartupFailed {
|
||||
port: u16,
|
||||
device: String,
|
||||
log_path: Option<String>,
|
||||
detail: String,
|
||||
},
|
||||
LlamaStarting {
|
||||
model: Option<String>,
|
||||
http_port: u16,
|
||||
ctx_size: Option<u32>,
|
||||
log_path: Option<String>,
|
||||
},
|
||||
LlamaReady {
|
||||
model: Option<String>,
|
||||
port: u16,
|
||||
ctx_size: Option<u32>,
|
||||
log_path: Option<String>,
|
||||
},
|
||||
LlamaStartupFailed {
|
||||
model: Option<String>,
|
||||
http_port: u16,
|
||||
ctx_size: Option<u32>,
|
||||
log_path: Option<String>,
|
||||
detail: String,
|
||||
},
|
||||
ModelReady {
|
||||
model: String,
|
||||
internal_port: Option<u16>,
|
||||
role: Option<String>,
|
||||
},
|
||||
MultiModelMode {
|
||||
count: usize,
|
||||
models: Vec<String>,
|
||||
},
|
||||
WebserverStarting {
|
||||
url: String,
|
||||
},
|
||||
WebserverReady {
|
||||
url: String,
|
||||
},
|
||||
ApiStarting {
|
||||
url: String,
|
||||
},
|
||||
ApiReady {
|
||||
url: String,
|
||||
},
|
||||
RuntimeReady {
|
||||
api_url: String,
|
||||
console_url: Option<String>,
|
||||
api_port: u16,
|
||||
console_port: Option<u16>,
|
||||
models_count: Option<usize>,
|
||||
pi_command: Option<String>,
|
||||
goose_command: Option<String>,
|
||||
},
|
||||
ModelDownloadProgress {
|
||||
label: String,
|
||||
file: Option<String>,
|
||||
downloaded_bytes: Option<u64>,
|
||||
total_bytes: Option<u64>,
|
||||
status: ModelProgressStatus,
|
||||
},
|
||||
RequestRouted {
|
||||
model: String,
|
||||
target: String,
|
||||
},
|
||||
Warning {
|
||||
message: String,
|
||||
context: Option<String>,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
context: Option<String>,
|
||||
},
|
||||
ShutdownRequested {
|
||||
signal: &'static str,
|
||||
},
|
||||
Shutdown {
|
||||
reason: Option<String>,
|
||||
},
|
||||
LlamaNativeLog {
|
||||
message: String,
|
||||
category: &'static str,
|
||||
params: Vec<(String, Value)>,
|
||||
},
|
||||
}
|
||||
|
||||
impl OutputEvent {
|
||||
pub fn event_name(&self) -> &'static str {
|
||||
match self {
|
||||
OutputEvent::Info { .. } => "info",
|
||||
OutputEvent::Startup { .. } => "startup",
|
||||
OutputEvent::LaunchPlan { .. } => "launch_plan",
|
||||
OutputEvent::NodeIdentity { .. } => "node_identity",
|
||||
OutputEvent::InviteToken { .. } => "invite_token",
|
||||
OutputEvent::DiscoveryStarting { .. } => "discovery_starting",
|
||||
OutputEvent::MeshFound { .. } => "mesh_found",
|
||||
OutputEvent::DiscoveryJoined { .. } => "discovery_joined",
|
||||
OutputEvent::DiscoveryFailed { .. } => "discovery_failed",
|
||||
OutputEvent::WaitingForPeers { .. } => "waiting_for_peers",
|
||||
OutputEvent::PassiveMode { .. } => "passive_mode",
|
||||
OutputEvent::PeerJoined { .. } => "peer_joined",
|
||||
OutputEvent::PeerLeft { .. } => "peer_left",
|
||||
OutputEvent::ModelQueued { .. } => "model_queued",
|
||||
OutputEvent::ModelLoading { .. } => "model_loading",
|
||||
OutputEvent::ModelLoaded { .. } => "model_loaded",
|
||||
OutputEvent::ModelUnloading { .. } => "model_unloading",
|
||||
OutputEvent::ModelUnloaded { .. } => "model_unloaded",
|
||||
OutputEvent::HostElected { .. } => "host_elected",
|
||||
OutputEvent::RpcServerStarting { .. } => "rpc_server_starting",
|
||||
OutputEvent::RpcReady { .. } => "rpc_ready",
|
||||
OutputEvent::RpcStartupFailed { .. } => "rpc_startup_failed",
|
||||
OutputEvent::LlamaStarting { .. } => "llama_starting",
|
||||
OutputEvent::LlamaReady { .. } => "llama_ready",
|
||||
OutputEvent::LlamaStartupFailed { .. } => "llama_startup_failed",
|
||||
OutputEvent::ModelReady { .. } => "model_ready",
|
||||
OutputEvent::MultiModelMode { .. } => "multi_model_mode",
|
||||
OutputEvent::WebserverStarting { .. } => "webserver_starting",
|
||||
OutputEvent::WebserverReady { .. } => "webserver_ready",
|
||||
OutputEvent::ApiStarting { .. } => "api_starting",
|
||||
OutputEvent::ApiReady { .. } => "api_ready",
|
||||
OutputEvent::RuntimeReady { .. } => "ready",
|
||||
OutputEvent::ModelDownloadProgress { .. } => "model_download_progress",
|
||||
OutputEvent::RequestRouted { .. } => "request_routed",
|
||||
OutputEvent::Warning { .. } => "warning",
|
||||
OutputEvent::Error { .. } => "error",
|
||||
OutputEvent::ShutdownRequested { signal } => signal,
|
||||
OutputEvent::Shutdown { .. } => "shutdown",
|
||||
OutputEvent::LlamaNativeLog { category, .. } => category,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn level(&self) -> OutputLevel {
|
||||
match self {
|
||||
OutputEvent::RpcStartupFailed { .. } | OutputEvent::LlamaStartupFailed { .. } => {
|
||||
OutputLevel::Error
|
||||
}
|
||||
OutputEvent::LlamaNativeLog { .. } => OutputLevel::Debug,
|
||||
OutputEvent::Warning { .. } => OutputLevel::Warn,
|
||||
OutputEvent::Error { .. } => OutputLevel::Error,
|
||||
_ => OutputLevel::Info,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
OutputEvent::Info { message, .. } => message.clone(),
|
||||
OutputEvent::Startup { message, .. } => message
|
||||
.clone()
|
||||
.unwrap_or_else(|| "mesh-llm starting".to_string()),
|
||||
OutputEvent::LaunchPlan { plan } => format!(
|
||||
"startup plan ready ({} process(es), {} endpoint(s), {} model(s))",
|
||||
plan.llama_process_rows.len(),
|
||||
plan.webserver_rows.len(),
|
||||
plan.loaded_model_rows.len()
|
||||
),
|
||||
OutputEvent::NodeIdentity { node_id, mesh_id } => match mesh_id {
|
||||
Some(mesh_id) => format!("node {node_id} joined mesh {mesh_id}"),
|
||||
None => format!("node {node_id} initialized"),
|
||||
},
|
||||
OutputEvent::InviteToken {
|
||||
mesh_id, mesh_name, ..
|
||||
} => {
|
||||
let mesh_label = format_invite_mesh_label(mesh_name.as_deref(), mesh_id);
|
||||
format!("invite token ready for mesh {mesh_label}")
|
||||
}
|
||||
OutputEvent::DiscoveryStarting { source } => format!("discovering mesh via {source}"),
|
||||
OutputEvent::MeshFound { mesh, peers, .. } => {
|
||||
format!("discovered mesh {mesh} ({peers} peer(s))")
|
||||
}
|
||||
OutputEvent::DiscoveryJoined { mesh } => format!("joined mesh {mesh}"),
|
||||
OutputEvent::DiscoveryFailed { message, detail } => match detail {
|
||||
Some(detail) => format!("{message}: {detail}"),
|
||||
None => message.clone(),
|
||||
},
|
||||
OutputEvent::WaitingForPeers { detail } => detail
|
||||
.clone()
|
||||
.unwrap_or_else(|| "waiting for peers".to_string()),
|
||||
OutputEvent::PassiveMode {
|
||||
role,
|
||||
status,
|
||||
capacity_gb,
|
||||
models_on_disk,
|
||||
detail,
|
||||
} => {
|
||||
let mut line = detail
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{role} {}", status.as_str()));
|
||||
if let Some(capacity_gb) = capacity_gb {
|
||||
line.push_str(&format!(" ({capacity_gb:.1}GB capacity)"));
|
||||
}
|
||||
if let Some(models_on_disk) = models_on_disk
|
||||
&& !models_on_disk.is_empty()
|
||||
{
|
||||
line.push_str(&format!(" models={}", models_on_disk.join(", ")));
|
||||
}
|
||||
line
|
||||
}
|
||||
OutputEvent::PeerJoined { peer_id, .. } => format!("peer {peer_id} joined"),
|
||||
OutputEvent::PeerLeft { peer_id, .. } => format!("peer {peer_id} left"),
|
||||
OutputEvent::ModelQueued { model } => format!("queued model {model}"),
|
||||
OutputEvent::ModelLoading { model, .. } => format!("loading model {model}"),
|
||||
OutputEvent::ModelLoaded { model, .. } => format!("loaded model {model}"),
|
||||
OutputEvent::ModelUnloading { model } => format!("unloading model {model}"),
|
||||
OutputEvent::ModelUnloaded { model } => format!("unloaded model {model}"),
|
||||
OutputEvent::HostElected {
|
||||
model, host, role, ..
|
||||
} => match role {
|
||||
Some(role) => format!("{model} elected {host} as {role}"),
|
||||
None => format!("{model} elected {host} as host"),
|
||||
},
|
||||
OutputEvent::RpcServerStarting { port, log_path, .. } => {
|
||||
let msg = format!("rpc-server starting on port {port}");
|
||||
append_log_path(msg, log_path)
|
||||
}
|
||||
OutputEvent::RpcReady { port, log_path, .. } => {
|
||||
let msg = format!("rpc-server ready on port {port}");
|
||||
append_log_path(msg, log_path)
|
||||
}
|
||||
OutputEvent::RpcStartupFailed {
|
||||
port,
|
||||
detail,
|
||||
log_path,
|
||||
..
|
||||
} => {
|
||||
let msg = format!("rpc-server failed to start on port {port}: {detail}");
|
||||
append_log_path(msg, log_path)
|
||||
}
|
||||
OutputEvent::LlamaStarting {
|
||||
http_port,
|
||||
log_path,
|
||||
..
|
||||
} => {
|
||||
let msg = format!("llama-server starting on port {http_port}");
|
||||
append_log_path(msg, log_path)
|
||||
}
|
||||
OutputEvent::LlamaReady { port, log_path, .. } => {
|
||||
let msg = format!("llama-server ready on port {port}");
|
||||
append_log_path(msg, log_path)
|
||||
}
|
||||
OutputEvent::LlamaStartupFailed {
|
||||
model,
|
||||
http_port,
|
||||
detail,
|
||||
log_path,
|
||||
..
|
||||
} => {
|
||||
let msg = match model {
|
||||
Some(model) => {
|
||||
format!(
|
||||
"llama-server failed to start for {model} on port {http_port}: {detail}"
|
||||
)
|
||||
}
|
||||
None => format!("llama-server failed to start on port {http_port}: {detail}"),
|
||||
};
|
||||
append_log_path(msg, log_path)
|
||||
}
|
||||
OutputEvent::ModelReady {
|
||||
model,
|
||||
internal_port,
|
||||
..
|
||||
} => match internal_port {
|
||||
Some(port) => format!("model {model} ready on port {port}"),
|
||||
None => format!("model {model} ready"),
|
||||
},
|
||||
OutputEvent::WebserverStarting { url } => format!("web console starting at {url}"),
|
||||
OutputEvent::WebserverReady { url } => format!("web console ready at {url}"),
|
||||
OutputEvent::ApiStarting { url } => format!("api starting at {url}"),
|
||||
OutputEvent::ApiReady { url } => format!("api ready at {url}"),
|
||||
OutputEvent::RuntimeReady { .. } => "mesh-llm runtime ready".to_string(),
|
||||
OutputEvent::ModelDownloadProgress {
|
||||
label,
|
||||
file,
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
status,
|
||||
} => format_model_download_progress_message(
|
||||
label,
|
||||
file.as_deref(),
|
||||
*downloaded_bytes,
|
||||
*total_bytes,
|
||||
status,
|
||||
),
|
||||
OutputEvent::MultiModelMode { count, models } => {
|
||||
if models.is_empty() {
|
||||
format!("Multi-model mode: {count} model(s)")
|
||||
} else {
|
||||
format!("Multi-model mode: {count} model(s): {}", models.join(", "))
|
||||
}
|
||||
}
|
||||
OutputEvent::RequestRouted { model, target } => {
|
||||
format!("routed request for {model} to {target}")
|
||||
}
|
||||
OutputEvent::Warning { message, .. } => message.clone(),
|
||||
OutputEvent::Error { message, .. } => message.clone(),
|
||||
OutputEvent::ShutdownRequested { signal } => format!("shutdown requested ({signal})"),
|
||||
OutputEvent::Shutdown { reason } => reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| "mesh-llm shutting down".to_string()),
|
||||
OutputEvent::LlamaNativeLog { message, .. } => message.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_log_path(message: String, log_path: &Option<String>) -> String {
|
||||
if let Some(path) = log_path {
|
||||
format!("{message}\n ↳ log={path}")
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
fn format_invite_mesh_label(mesh_name: Option<&str>, mesh_id: &str) -> String {
|
||||
match mesh_name.map(str::trim).filter(|name| !name.is_empty()) {
|
||||
Some(name) => format!("{name} ({mesh_id})"),
|
||||
None => mesh_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_model_download_progress_message(
|
||||
label: &str,
|
||||
file: Option<&str>,
|
||||
downloaded_bytes: Option<u64>,
|
||||
total_bytes: Option<u64>,
|
||||
status: &ModelProgressStatus,
|
||||
) -> String {
|
||||
let target = file.unwrap_or(label);
|
||||
if let Some(package) = label.strip_prefix("layer package ") {
|
||||
return match status {
|
||||
ModelProgressStatus::Ensuring => {
|
||||
format!("ensuring layer package artifact {target} for {package}")
|
||||
}
|
||||
ModelProgressStatus::Downloading => match (downloaded_bytes, total_bytes) {
|
||||
(Some(downloaded), Some(total)) if total > 0 => format!(
|
||||
"downloading layer package artifact {target} for {package} {}/{}",
|
||||
format_display_bytes(downloaded),
|
||||
format_display_bytes(total)
|
||||
),
|
||||
(Some(downloaded), _) if downloaded > 0 => format!(
|
||||
"downloading layer package artifact {target} for {package} {}",
|
||||
format_display_bytes(downloaded)
|
||||
),
|
||||
_ => format!("downloading layer package artifact {target} for {package}"),
|
||||
},
|
||||
ModelProgressStatus::Ready => match total_bytes {
|
||||
Some(total) if total > 0 => format!(
|
||||
"layer package artifact {target} ready for {package} ({})",
|
||||
format_display_bytes(total)
|
||||
),
|
||||
_ => format!("layer package artifact {target} ready for {package}"),
|
||||
},
|
||||
};
|
||||
}
|
||||
match status {
|
||||
ModelProgressStatus::Ensuring => format!("ensuring model {target}"),
|
||||
ModelProgressStatus::Downloading => match (downloaded_bytes, total_bytes) {
|
||||
(Some(downloaded), Some(total)) if total > 0 => format!(
|
||||
"downloading model {target} {}/{}",
|
||||
format_display_bytes(downloaded),
|
||||
format_display_bytes(total)
|
||||
),
|
||||
(Some(downloaded), _) if downloaded > 0 => {
|
||||
format!(
|
||||
"downloading model {target} {}",
|
||||
format_display_bytes(downloaded)
|
||||
)
|
||||
}
|
||||
_ => format!("downloading model {target}"),
|
||||
},
|
||||
ModelProgressStatus::Ready => match total_bytes {
|
||||
Some(total) if total > 0 => {
|
||||
format!("model {target} ready ({})", format_display_bytes(total))
|
||||
}
|
||||
_ => format!("model {target} ready"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn format_display_bytes(bytes: u64) -> String {
|
||||
if bytes >= 1_000_000_000 {
|
||||
format!("{:.1}GB", bytes as f64 / 1e9)
|
||||
} else if bytes >= 1_000_000 {
|
||||
format!("{:.0}MB", bytes as f64 / 1e6)
|
||||
} else if bytes >= 1_000 {
|
||||
format!("{:.0}KB", bytes as f64 / 1e3)
|
||||
} else {
|
||||
format!("{bytes}B")
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,8 @@ use std::sync::{
|
|||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) fn clear_stderr_line() -> Result<()> {
|
||||
if crate::cli::output::json_mode_enabled() {
|
||||
pub fn clear_stderr_line() -> Result<()> {
|
||||
if crate::json_mode_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
eprint!("\r\x1b[2K");
|
||||
|
|
@ -18,13 +18,13 @@ pub(crate) fn clear_stderr_line() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) struct SpinnerHandle {
|
||||
pub struct SpinnerHandle {
|
||||
done: Arc<AtomicBool>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl SpinnerHandle {
|
||||
pub(crate) fn finish(&mut self) {
|
||||
pub fn finish(&mut self) {
|
||||
self.done.store(true, Ordering::Relaxed);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
|
|
@ -39,8 +39,8 @@ impl Drop for SpinnerHandle {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_spinner(message: &str) -> SpinnerHandle {
|
||||
if crate::cli::output::json_mode_enabled() {
|
||||
pub fn start_spinner(message: &str) -> SpinnerHandle {
|
||||
if crate::json_mode_enabled() {
|
||||
return SpinnerHandle {
|
||||
done: Arc::new(AtomicBool::new(true)),
|
||||
thread: None,
|
||||
|
|
@ -70,25 +70,25 @@ pub(crate) fn start_spinner(message: &str) -> SpinnerHandle {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) struct DeterminateProgressLine {
|
||||
pub struct DeterminateProgressLine {
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl DeterminateProgressLine {
|
||||
pub(crate) fn new(prefix: impl Into<String>) -> Self {
|
||||
pub fn new(prefix: impl Into<String>) -> Self {
|
||||
Self {
|
||||
prefix: prefix.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn draw_counts(
|
||||
pub fn draw_counts(
|
||||
&self,
|
||||
label: &str,
|
||||
current: usize,
|
||||
total: usize,
|
||||
detail: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if crate::cli::output::json_mode_enabled() {
|
||||
if crate::json_mode_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
let percent = if total > 0 {
|
||||
|
|
@ -10,12 +10,10 @@ crate-type = ["cdylib", "staticlib", "rlib"]
|
|||
[features]
|
||||
default = []
|
||||
host = ["mesh-llm-node"]
|
||||
embedded-runtime = ["mesh-llm-host-runtime"]
|
||||
embedded-runtime = []
|
||||
|
||||
[dependencies]
|
||||
mesh-llm-api-server = { path = "../mesh-llm-api-server" }
|
||||
mesh-llm-console-server = { path = "../mesh-llm-console-server" }
|
||||
mesh-llm-host-runtime = { path = "../mesh-llm-host-runtime", default-features = false, optional = true }
|
||||
mesh-llm-sdk = { path = "../mesh-llm-sdk", default-features = false, features = ["client", "node", "console", "serving"] }
|
||||
mesh-llm-node = { path = "../mesh-llm-node", optional = true }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
|
|
@ -25,4 +23,4 @@ uniffi = "=0.31.0"
|
|||
uniffi = { version = "=0.31.0", features = ["build"] }
|
||||
|
||||
[dev-dependencies]
|
||||
mesh-llm-api-server = { path = "../mesh-llm-api-server" }
|
||||
mesh-llm-sdk = { path = "../mesh-llm-sdk", default-features = false, features = ["client", "node", "console"] }
|
||||
|
|
|
|||
|
|
@ -5,16 +5,19 @@ language bindings, including model management, inference, and serving control
|
|||
when built with the host runtime feature.
|
||||
|
||||
This crate is the bridge used by the generated Swift and Kotlin SDKs. It should
|
||||
stay thin and map the public Rust API from `crates/mesh-llm-api-server/` into an FFI-safe surface.
|
||||
stay thin and map the canonical Rust API from `crates/mesh-llm-sdk/` into an
|
||||
FFI-safe surface.
|
||||
|
||||
Layering:
|
||||
|
||||
- `crates/mesh-client/` implements low-level client behavior
|
||||
- `crates/mesh-llm-api-server/` defines the public Rust SDK
|
||||
- `crates/mesh-llm-api-client/` and `crates/mesh-llm-api-server/` implement the
|
||||
lower-level Rust client and node APIs
|
||||
- `crates/mesh-llm-sdk/` defines the public SDK facade and feature model
|
||||
- `crates/mesh-llm-ffi/` adapts that SDK for cross-language consumers
|
||||
|
||||
The FFI layer should expose public model ids as the same full model refs used by
|
||||
mesh and `/v1/models`; it should not derive identities from GGUF filenames.
|
||||
|
||||
Application code should usually depend on `crates/mesh-llm-api-server/` directly unless it is
|
||||
building a non-Rust binding.
|
||||
Application code should usually depend on `crates/mesh-llm-sdk/` directly unless
|
||||
it is building a non-Rust binding.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
use mesh_llm_api_server::OwnerKeypair;
|
||||
use mesh_llm_api_server::events::{Event, EventListener as CoreEventListener};
|
||||
use mesh_llm_api_server::{
|
||||
ChatMessage, ChatRequest, ClientBuilder, DevicePolicy as ApiDevicePolicy, InviteToken,
|
||||
MeshApiError, MeshClient, MeshNode, ModelKind as ApiModelKind, ModelSource as ApiModelSource,
|
||||
PublicMeshQuery as ApiPublicMeshQuery, RequestId, ResponsesRequest,
|
||||
ServingModelState as ApiServingModelState, UnloadModelOptions as ApiUnloadModelOptions,
|
||||
UnloadTarget as ApiUnloadTarget, create_auto_client as sdk_create_auto_client,
|
||||
create_auto_node as sdk_create_auto_node, discover_public_meshes as sdk_discover_public_meshes,
|
||||
};
|
||||
#[cfg(feature = "embedded-runtime")]
|
||||
use mesh_llm_host_runtime::sdk::{EmbeddedChatMessage, EmbeddedServingController};
|
||||
use mesh_llm_sdk::embedded_runtime::{EmbeddedChatMessage, EmbeddedServingController};
|
||||
use mesh_llm_sdk::events::{Event, EventListener as CoreEventListener};
|
||||
use mesh_llm_sdk::node as sdk_node;
|
||||
use mesh_llm_sdk::node::{
|
||||
DevicePolicy as ApiDevicePolicy, MeshNode, ModelKind as ApiModelKind,
|
||||
ModelSource as ApiModelSource, ServingModelState as ApiServingModelState,
|
||||
UnloadModelOptions as ApiUnloadModelOptions, UnloadTarget as ApiUnloadTarget,
|
||||
create_auto_node as sdk_create_auto_node,
|
||||
};
|
||||
use mesh_llm_sdk::{
|
||||
ChatMessage, ChatRequest, ClientBuilder, InviteToken, MeshApiError, MeshClient, OwnerKeypair,
|
||||
PublicMeshQuery as ApiPublicMeshQuery, RequestId, ResponsesRequest,
|
||||
create_auto_client as sdk_create_auto_client,
|
||||
discover_public_meshes as sdk_discover_public_meshes,
|
||||
};
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
|
@ -63,6 +68,8 @@ pub enum FfiError {
|
|||
ServingUnsupported(String),
|
||||
#[error("console failed: {0}")]
|
||||
ConsoleFailed(String),
|
||||
#[error("native runtime failed: {0}")]
|
||||
NativeRuntimeFailed(String),
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record)]
|
||||
|
|
@ -311,11 +318,72 @@ pub enum ClientEvent {
|
|||
Disconnected { reason: String },
|
||||
}
|
||||
|
||||
#[derive(uniffi::Enum)]
|
||||
pub enum NativeRuntimeVerificationPolicyNative {
|
||||
RequireChecksum,
|
||||
RequireChecksumAndSignature,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Enum)]
|
||||
pub enum NativeRuntimePruneModeNative {
|
||||
KeepActiveAndPrevious,
|
||||
ActiveOnly,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record)]
|
||||
pub struct NativeRuntimeInstallOptionsNative {
|
||||
pub mesh_version: Option<String>,
|
||||
pub skippy_abi_version: Option<String>,
|
||||
pub selection: String,
|
||||
pub manifest_path: Option<String>,
|
||||
pub manifest_url: Option<String>,
|
||||
pub bundle_dirs: Vec<String>,
|
||||
pub cache_dir: Option<String>,
|
||||
pub verification_policy: NativeRuntimeVerificationPolicyNative,
|
||||
pub allow_download: bool,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record)]
|
||||
pub struct NativeRuntimeDownloadProgressNative {
|
||||
pub native_runtime_id: String,
|
||||
pub url: String,
|
||||
pub downloaded_bytes: u64,
|
||||
pub total_bytes: Option<u64>,
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record)]
|
||||
pub struct InstalledNativeRuntimeNative {
|
||||
pub mesh_version: String,
|
||||
pub native_runtime_id: String,
|
||||
pub flavor: String,
|
||||
pub path: String,
|
||||
pub skippy_abi_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record)]
|
||||
pub struct NativeRuntimeInstallOutcomeNative {
|
||||
pub status: String,
|
||||
pub runtime: InstalledNativeRuntimeNative,
|
||||
pub selected_native_runtime_id: String,
|
||||
pub selected_source: String,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record)]
|
||||
pub struct NativeRuntimePruneResultNative {
|
||||
pub removed_dirs: Vec<String>,
|
||||
}
|
||||
|
||||
#[uniffi::export(callback_interface)]
|
||||
pub trait EventListener: Send + Sync {
|
||||
fn on_event(&self, event: ClientEvent);
|
||||
}
|
||||
|
||||
#[uniffi::export(callback_interface)]
|
||||
pub trait NativeRuntimeProgressListener: Send + Sync {
|
||||
fn on_progress(&self, event: NativeRuntimeDownloadProgressNative);
|
||||
}
|
||||
|
||||
struct EventListenerBridge {
|
||||
inner: Box<dyn EventListener>,
|
||||
}
|
||||
|
|
@ -359,7 +427,7 @@ pub struct MeshNodeHandle {
|
|||
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct ConsoleHandle {
|
||||
inner: Mutex<Option<mesh_llm_console_server::ConsoleServerHandle>>,
|
||||
inner: Mutex<Option<mesh_llm_sdk::console::ConsoleServerHandle>>,
|
||||
url: String,
|
||||
}
|
||||
|
||||
|
|
@ -374,6 +442,69 @@ pub fn generate_owner_keypair_hex() -> String {
|
|||
OwnerKeypair::generate().to_hex()
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn current_mesh_version() -> String {
|
||||
mesh_llm_sdk::native_runtime::CURRENT_MESH_VERSION.to_string()
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn current_skippy_abi_version() -> String {
|
||||
mesh_llm_sdk::native_runtime::current_skippy_abi_version()
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn install_native_runtime(
|
||||
options: NativeRuntimeInstallOptionsNative,
|
||||
progress: Option<Box<dyn NativeRuntimeProgressListener>>,
|
||||
) -> Result<NativeRuntimeInstallOutcomeNative, FfiError> {
|
||||
let options = runtime_install_options(options, progress)?;
|
||||
block_on(mesh_llm_sdk::native_runtime::install_native_runtime(
|
||||
options,
|
||||
))
|
||||
.map(NativeRuntimeInstallOutcomeNative::from)
|
||||
.map_err(map_native_runtime_error)
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn installed_native_runtimes(
|
||||
cache_dir: Option<String>,
|
||||
) -> Result<Vec<InstalledNativeRuntimeNative>, FfiError> {
|
||||
native_runtime_cache(cache_dir)?
|
||||
.installed()
|
||||
.map(|runtimes| {
|
||||
runtimes
|
||||
.into_iter()
|
||||
.map(InstalledNativeRuntimeNative::from)
|
||||
.collect()
|
||||
})
|
||||
.map_err(map_native_runtime_error)
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn remove_native_runtime(
|
||||
cache_dir: Option<String>,
|
||||
mesh_version: String,
|
||||
native_runtime_id: String,
|
||||
) -> Result<bool, FfiError> {
|
||||
native_runtime_cache(cache_dir)?
|
||||
.remove(&mesh_version, &native_runtime_id)
|
||||
.map_err(map_native_runtime_error)
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn prune_native_runtimes(
|
||||
cache_dir: Option<String>,
|
||||
active_mesh_version: Option<String>,
|
||||
mode: NativeRuntimePruneModeNative,
|
||||
) -> Result<NativeRuntimePruneResultNative, FfiError> {
|
||||
let active_mesh_version = active_mesh_version
|
||||
.unwrap_or_else(|| mesh_llm_sdk::native_runtime::CURRENT_MESH_VERSION.to_string());
|
||||
native_runtime_cache(cache_dir)?
|
||||
.prune(&active_mesh_version, mode.into())
|
||||
.map(NativeRuntimePruneResultNative::from)
|
||||
.map_err(map_native_runtime_error)
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn discover_public_meshes(query: PublicMeshQuery) -> Result<Vec<PublicMesh>, FfiError> {
|
||||
block_on(sdk_discover_public_meshes(query.into()))
|
||||
|
|
@ -587,7 +718,7 @@ impl MeshNodeHandle {
|
|||
}
|
||||
|
||||
pub fn status(&self) -> ClientStatus {
|
||||
let status = block_on(self.node.status().node()).unwrap_or(mesh_llm_api_server::Status {
|
||||
let status = block_on(self.node.status().node()).unwrap_or(sdk_node::Status {
|
||||
connected: false,
|
||||
peer_count: 0,
|
||||
});
|
||||
|
|
@ -697,14 +828,10 @@ impl MeshNodeHandle {
|
|||
}
|
||||
|
||||
pub fn search_models(&self, query: ModelSearchQuery) -> Result<Vec<ModelSummary>, FfiError> {
|
||||
block_on(
|
||||
self.node
|
||||
.models()
|
||||
.search(mesh_llm_api_server::ModelSearchQuery {
|
||||
query: query.query,
|
||||
limit: query.limit.map(|limit| limit as usize),
|
||||
}),
|
||||
)
|
||||
block_on(self.node.models().search(sdk_node::ModelSearchQuery {
|
||||
query: query.query,
|
||||
limit: query.limit.map(|limit| limit as usize),
|
||||
}))
|
||||
.map(|models| models.into_iter().map(ModelSummary::from).collect())
|
||||
.map_err(map_model_error)
|
||||
}
|
||||
|
|
@ -731,7 +858,7 @@ impl MeshNodeHandle {
|
|||
block_on(
|
||||
self.node
|
||||
.models()
|
||||
.download(model_ref, mesh_llm_api_server::DownloadOptions),
|
||||
.download(model_ref, sdk_node::DownloadOptions),
|
||||
)
|
||||
.map(DownloadedModel::from)
|
||||
.map_err(map_model_error)
|
||||
|
|
@ -744,7 +871,7 @@ impl MeshNodeHandle {
|
|||
) -> Result<DeleteModelResult, FfiError> {
|
||||
block_on(self.node.models().delete(
|
||||
model_ref,
|
||||
mesh_llm_api_server::DeleteModelOptions {
|
||||
sdk_node::DeleteModelOptions {
|
||||
force: options.force,
|
||||
},
|
||||
))
|
||||
|
|
@ -753,13 +880,9 @@ impl MeshNodeHandle {
|
|||
}
|
||||
|
||||
pub fn cleanup_models(&self, policy: CleanupPolicy) -> Result<CleanupResult, FfiError> {
|
||||
block_on(
|
||||
self.node
|
||||
.models()
|
||||
.cleanup(mesh_llm_api_server::CleanupPolicy {
|
||||
remove_all: policy.remove_all,
|
||||
}),
|
||||
)
|
||||
block_on(self.node.models().cleanup(sdk_node::CleanupPolicy {
|
||||
remove_all: policy.remove_all,
|
||||
}))
|
||||
.map(CleanupResult::from)
|
||||
.map_err(map_model_error)
|
||||
}
|
||||
|
|
@ -768,7 +891,7 @@ impl MeshNodeHandle {
|
|||
block_on(
|
||||
self.node
|
||||
.models()
|
||||
.prune_derived_cache(mesh_llm_api_server::PrunePolicy {
|
||||
.prune_derived_cache(sdk_node::PrunePolicy {
|
||||
remove_all: policy.remove_all,
|
||||
}),
|
||||
)
|
||||
|
|
@ -783,7 +906,7 @@ impl MeshNodeHandle {
|
|||
) -> Result<ServedModel, FfiError> {
|
||||
block_on(self.node.serving().load(
|
||||
model_ref,
|
||||
mesh_llm_api_server::LoadModelOptions {
|
||||
sdk_node::LoadModelOptions {
|
||||
device_policy: options.device_policy.into(),
|
||||
},
|
||||
))
|
||||
|
|
@ -842,8 +965,8 @@ impl MeshNodeHandle {
|
|||
&self,
|
||||
options: ConsoleOptionsNative,
|
||||
) -> Result<Arc<ConsoleHandle>, FfiError> {
|
||||
let handle = block_on(mesh_llm_console_server::start_file_console(
|
||||
mesh_llm_console_server::ConsoleServerOptions {
|
||||
let handle = block_on(mesh_llm_sdk::console::start_file_console(
|
||||
mesh_llm_sdk::console::ConsoleServerOptions {
|
||||
asset_dir: options.asset_dir.into(),
|
||||
port: options.port.unwrap_or(0),
|
||||
listen_all: options.listen_all,
|
||||
|
|
@ -884,6 +1007,47 @@ fn non_empty_path(value: Option<String>) -> Option<String> {
|
|||
})
|
||||
}
|
||||
|
||||
fn runtime_install_options(
|
||||
options: NativeRuntimeInstallOptionsNative,
|
||||
progress: Option<Box<dyn NativeRuntimeProgressListener>>,
|
||||
) -> Result<mesh_llm_sdk::native_runtime::NativeRuntimeInstallOptions, FfiError> {
|
||||
let progress = progress.map(runtime_progress_callback);
|
||||
Ok(mesh_llm_sdk::native_runtime::NativeRuntimeInstallOptions {
|
||||
mesh_version: options
|
||||
.mesh_version
|
||||
.unwrap_or_else(|| mesh_llm_sdk::native_runtime::CURRENT_MESH_VERSION.to_string()),
|
||||
skippy_abi_version: options
|
||||
.skippy_abi_version
|
||||
.unwrap_or_else(mesh_llm_sdk::native_runtime::current_skippy_abi_version),
|
||||
selection: mesh_llm_sdk::native_runtime::RuntimeSelection::parse(Some(
|
||||
options.selection.as_str(),
|
||||
))
|
||||
.map_err(map_native_runtime_error)?,
|
||||
manifest_path: options.manifest_path.map(PathBuf::from),
|
||||
manifest_url: options.manifest_url,
|
||||
bundle_dirs: options.bundle_dirs.into_iter().map(PathBuf::from).collect(),
|
||||
cache_dir: options.cache_dir.map(PathBuf::from),
|
||||
verification_policy: options.verification_policy.into(),
|
||||
progress,
|
||||
allow_download: options.allow_download,
|
||||
})
|
||||
}
|
||||
|
||||
fn runtime_progress_callback(
|
||||
listener: Box<dyn NativeRuntimeProgressListener>,
|
||||
) -> mesh_llm_sdk::native_runtime::NativeRuntimeDownloadProgressCallback {
|
||||
let listener: Arc<dyn NativeRuntimeProgressListener> = Arc::from(listener);
|
||||
Arc::new(move |event| listener.on_progress(event.into()))
|
||||
}
|
||||
|
||||
fn native_runtime_cache(
|
||||
cache_dir: Option<String>,
|
||||
) -> Result<mesh_llm_sdk::native_runtime::NativeRuntimeCache, FfiError> {
|
||||
let cache_dir = cache_dir.map(PathBuf::from);
|
||||
mesh_llm_sdk::native_runtime::native_runtime_cache(cache_dir.as_deref())
|
||||
.map_err(map_native_runtime_error)
|
||||
}
|
||||
|
||||
fn parse_owner_keypair(owner_keypair_bytes_hex: &str) -> Result<OwnerKeypair, FfiError> {
|
||||
// An empty keypair is rejected rather than silently generating a fresh identity:
|
||||
// a caller that forgets to pass their persisted owner keypair would otherwise
|
||||
|
|
@ -940,6 +1104,10 @@ fn map_stream_error(error: MeshApiError) -> FfiError {
|
|||
}
|
||||
}
|
||||
|
||||
fn map_native_runtime_error(error: impl ToString) -> FfiError {
|
||||
FfiError::NativeRuntimeFailed(error.to_string())
|
||||
}
|
||||
|
||||
impl From<ChatRequestNative> for ChatRequest {
|
||||
fn from(value: ChatRequestNative) -> Self {
|
||||
Self {
|
||||
|
|
@ -979,8 +1147,8 @@ impl From<PublicMeshQuery> for ApiPublicMeshQuery {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::PublicMesh> for PublicMesh {
|
||||
fn from(value: mesh_llm_api_server::PublicMesh) -> Self {
|
||||
impl From<sdk_node::PublicMesh> for PublicMesh {
|
||||
fn from(value: sdk_node::PublicMesh) -> Self {
|
||||
Self {
|
||||
invite_token: value.invite_token,
|
||||
serving: value.serving,
|
||||
|
|
@ -1000,18 +1168,18 @@ impl From<mesh_llm_api_server::PublicMesh> for PublicMesh {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::CapabilityLevel> for CapabilityLevel {
|
||||
fn from(value: mesh_llm_api_server::CapabilityLevel) -> Self {
|
||||
impl From<sdk_node::CapabilityLevel> for CapabilityLevel {
|
||||
fn from(value: sdk_node::CapabilityLevel) -> Self {
|
||||
match value {
|
||||
mesh_llm_api_server::CapabilityLevel::None => Self::None,
|
||||
mesh_llm_api_server::CapabilityLevel::Likely => Self::Likely,
|
||||
mesh_llm_api_server::CapabilityLevel::Supported => Self::Supported,
|
||||
sdk_node::CapabilityLevel::None => Self::None,
|
||||
sdk_node::CapabilityLevel::Likely => Self::Likely,
|
||||
sdk_node::CapabilityLevel::Supported => Self::Supported,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::ModelCapabilities> for ModelCapabilities {
|
||||
fn from(value: mesh_llm_api_server::ModelCapabilities) -> Self {
|
||||
impl From<sdk_node::ModelCapabilities> for ModelCapabilities {
|
||||
fn from(value: sdk_node::ModelCapabilities) -> Self {
|
||||
Self {
|
||||
multimodal: value.multimodal,
|
||||
vision: value.vision.into(),
|
||||
|
|
@ -1023,8 +1191,8 @@ impl From<mesh_llm_api_server::ModelCapabilities> for ModelCapabilities {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::ModelSummary> for ModelSummary {
|
||||
fn from(value: mesh_llm_api_server::ModelSummary) -> Self {
|
||||
impl From<sdk_node::ModelSummary> for ModelSummary {
|
||||
fn from(value: sdk_node::ModelSummary) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
name: value.name,
|
||||
|
|
@ -1056,8 +1224,8 @@ impl From<ApiModelKind> for ModelKind {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::ModelDetails> for ModelDetails {
|
||||
fn from(value: mesh_llm_api_server::ModelDetails) -> Self {
|
||||
impl From<sdk_node::ModelDetails> for ModelDetails {
|
||||
fn from(value: sdk_node::ModelDetails) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
name: value.name,
|
||||
|
|
@ -1076,8 +1244,8 @@ impl From<mesh_llm_api_server::ModelDetails> for ModelDetails {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::InstalledModel> for InstalledModel {
|
||||
fn from(value: mesh_llm_api_server::InstalledModel) -> Self {
|
||||
impl From<sdk_node::InstalledModel> for InstalledModel {
|
||||
fn from(value: sdk_node::InstalledModel) -> Self {
|
||||
Self {
|
||||
model_ref: value.model_ref,
|
||||
path: path_to_string(value.path),
|
||||
|
|
@ -1087,16 +1255,16 @@ impl From<mesh_llm_api_server::InstalledModel> for InstalledModel {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::ModelCacheStatus> for ModelCacheStatus {
|
||||
fn from(value: mesh_llm_api_server::ModelCacheStatus) -> Self {
|
||||
impl From<sdk_node::ModelCacheStatus> for ModelCacheStatus {
|
||||
fn from(value: sdk_node::ModelCacheStatus) -> Self {
|
||||
Self {
|
||||
cache_dir: value.cache_dir.map(path_to_string),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::DownloadedModel> for DownloadedModel {
|
||||
fn from(value: mesh_llm_api_server::DownloadedModel) -> Self {
|
||||
impl From<sdk_node::DownloadedModel> for DownloadedModel {
|
||||
fn from(value: sdk_node::DownloadedModel) -> Self {
|
||||
Self {
|
||||
model_ref: value.model_ref,
|
||||
paths: value.paths.into_iter().map(path_to_string).collect(),
|
||||
|
|
@ -1106,8 +1274,8 @@ impl From<mesh_llm_api_server::DownloadedModel> for DownloadedModel {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::DeleteModelResult> for DeleteModelResult {
|
||||
fn from(value: mesh_llm_api_server::DeleteModelResult) -> Self {
|
||||
impl From<sdk_node::DeleteModelResult> for DeleteModelResult {
|
||||
fn from(value: sdk_node::DeleteModelResult) -> Self {
|
||||
Self {
|
||||
deleted_paths: value
|
||||
.deleted_paths
|
||||
|
|
@ -1119,8 +1287,8 @@ impl From<mesh_llm_api_server::DeleteModelResult> for DeleteModelResult {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::CleanupResult> for CleanupResult {
|
||||
fn from(value: mesh_llm_api_server::CleanupResult) -> Self {
|
||||
impl From<sdk_node::CleanupResult> for CleanupResult {
|
||||
fn from(value: sdk_node::CleanupResult) -> Self {
|
||||
Self {
|
||||
deleted_paths: value
|
||||
.deleted_paths
|
||||
|
|
@ -1137,8 +1305,8 @@ impl From<mesh_llm_api_server::CleanupResult> for CleanupResult {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::PruneResult> for PruneResult {
|
||||
fn from(value: mesh_llm_api_server::PruneResult) -> Self {
|
||||
impl From<sdk_node::PruneResult> for PruneResult {
|
||||
fn from(value: sdk_node::PruneResult) -> Self {
|
||||
Self {
|
||||
deleted_paths: value
|
||||
.deleted_paths
|
||||
|
|
@ -1173,8 +1341,8 @@ impl From<ApiServingModelState> for ServingModelState {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::ServedModel> for ServedModel {
|
||||
fn from(value: mesh_llm_api_server::ServedModel) -> Self {
|
||||
impl From<sdk_node::ServedModel> for ServedModel {
|
||||
fn from(value: sdk_node::ServedModel) -> Self {
|
||||
Self {
|
||||
model_ref: value.model_ref,
|
||||
model_id: value.model_id,
|
||||
|
|
@ -1188,8 +1356,8 @@ impl From<mesh_llm_api_server::ServedModel> for ServedModel {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_api_server::ServingStatus> for ServingStatus {
|
||||
fn from(value: mesh_llm_api_server::ServingStatus) -> Self {
|
||||
impl From<sdk_node::ServingStatus> for ServingStatus {
|
||||
fn from(value: sdk_node::ServingStatus) -> Self {
|
||||
Self {
|
||||
enabled: value.enabled,
|
||||
models: value.models.into_iter().map(ServedModel::from).collect(),
|
||||
|
|
@ -1197,6 +1365,94 @@ impl From<mesh_llm_api_server::ServingStatus> for ServingStatus {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<NativeRuntimeVerificationPolicyNative>
|
||||
for mesh_llm_sdk::native_runtime::NativeRuntimeVerificationPolicy
|
||||
{
|
||||
fn from(value: NativeRuntimeVerificationPolicyNative) -> Self {
|
||||
match value {
|
||||
NativeRuntimeVerificationPolicyNative::RequireChecksum => Self::RequireChecksum,
|
||||
NativeRuntimeVerificationPolicyNative::RequireChecksumAndSignature => {
|
||||
Self::RequireChecksumAndSignature
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NativeRuntimePruneModeNative> for mesh_llm_sdk::native_runtime::NativeRuntimePruneMode {
|
||||
fn from(value: NativeRuntimePruneModeNative) -> Self {
|
||||
match value {
|
||||
NativeRuntimePruneModeNative::KeepActiveAndPrevious => Self::KeepActiveAndPrevious,
|
||||
NativeRuntimePruneModeNative::ActiveOnly => Self::ActiveOnly,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_sdk::native_runtime::NativeRuntimeDownloadProgress>
|
||||
for NativeRuntimeDownloadProgressNative
|
||||
{
|
||||
fn from(value: mesh_llm_sdk::native_runtime::NativeRuntimeDownloadProgress) -> Self {
|
||||
Self {
|
||||
native_runtime_id: value.native_runtime_id,
|
||||
url: value.url,
|
||||
downloaded_bytes: value.downloaded_bytes,
|
||||
total_bytes: value.total_bytes,
|
||||
finished: value.finished,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_sdk::native_runtime::InstalledNativeRuntime> for InstalledNativeRuntimeNative {
|
||||
fn from(value: mesh_llm_sdk::native_runtime::InstalledNativeRuntime) -> Self {
|
||||
Self {
|
||||
mesh_version: value.mesh_version,
|
||||
native_runtime_id: value.native_runtime_id,
|
||||
flavor: value.flavor,
|
||||
path: path_to_string(value.path),
|
||||
skippy_abi_version: Some(value.manifest.runtime.skippy_abi),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_sdk::native_runtime::NativeRuntimeInstallOutcome>
|
||||
for NativeRuntimeInstallOutcomeNative
|
||||
{
|
||||
fn from(value: mesh_llm_sdk::native_runtime::NativeRuntimeInstallOutcome) -> Self {
|
||||
Self {
|
||||
status: match value.status {
|
||||
mesh_llm_sdk::native_runtime::NativeRuntimeInstallStatus::AlreadyInstalled => {
|
||||
"already_installed".to_string()
|
||||
}
|
||||
mesh_llm_sdk::native_runtime::NativeRuntimeInstallStatus::Installed => {
|
||||
"installed".to_string()
|
||||
}
|
||||
},
|
||||
runtime: value.runtime.into(),
|
||||
selected_native_runtime_id: value.resolution.selected.id,
|
||||
selected_source: native_runtime_source_name(&value.resolution.source),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<mesh_llm_sdk::native_runtime::CachePrunePlan> for NativeRuntimePruneResultNative {
|
||||
fn from(value: mesh_llm_sdk::native_runtime::CachePrunePlan) -> Self {
|
||||
Self {
|
||||
removed_dirs: value.remove_dirs.into_iter().map(path_to_string).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn native_runtime_source_name(
|
||||
source: &mesh_llm_sdk::native_runtime::NativeRuntimeSource,
|
||||
) -> String {
|
||||
match source {
|
||||
mesh_llm_sdk::native_runtime::NativeRuntimeSource::Installed { .. } => "installed",
|
||||
mesh_llm_sdk::native_runtime::NativeRuntimeSource::Bundle { .. } => "bundle",
|
||||
mesh_llm_sdk::native_runtime::NativeRuntimeSource::Download { .. } => "download",
|
||||
mesh_llm_sdk::native_runtime::NativeRuntimeSource::Missing => "missing",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
impl From<UnloadTarget> for ApiUnloadTarget {
|
||||
fn from(value: UnloadTarget) -> Self {
|
||||
match value {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,33 @@ namespace mesh_ffi {
|
|||
sequence<PublicMesh> discover_public_meshes(PublicMeshQuery query);
|
||||
|
||||
string generate_owner_keypair_hex();
|
||||
|
||||
string current_mesh_version();
|
||||
|
||||
string current_skippy_abi_version();
|
||||
|
||||
[Throws=FfiError]
|
||||
NativeRuntimeInstallOutcomeNative install_native_runtime(
|
||||
NativeRuntimeInstallOptionsNative options,
|
||||
NativeRuntimeProgressListener? progress
|
||||
);
|
||||
|
||||
[Throws=FfiError]
|
||||
sequence<InstalledNativeRuntimeNative> installed_native_runtimes(string? cache_dir);
|
||||
|
||||
[Throws=FfiError]
|
||||
boolean remove_native_runtime(
|
||||
string? cache_dir,
|
||||
string mesh_version,
|
||||
string native_runtime_id
|
||||
);
|
||||
|
||||
[Throws=FfiError]
|
||||
NativeRuntimePruneResultNative prune_native_runtimes(
|
||||
string? cache_dir,
|
||||
string? active_mesh_version,
|
||||
NativeRuntimePruneModeNative mode
|
||||
);
|
||||
};
|
||||
|
||||
[Error]
|
||||
|
|
@ -41,6 +68,7 @@ enum FfiError {
|
|||
"ServingFailed",
|
||||
"ServingUnsupported",
|
||||
"ConsoleFailed",
|
||||
"NativeRuntimeFailed",
|
||||
};
|
||||
|
||||
dictionary ConsoleOptionsNative {
|
||||
|
|
@ -159,6 +187,10 @@ callback interface EventListener {
|
|||
void on_event(ClientEvent event);
|
||||
};
|
||||
|
||||
callback interface NativeRuntimeProgressListener {
|
||||
void on_progress(NativeRuntimeDownloadProgressNative event);
|
||||
};
|
||||
|
||||
dictionary ModelNative {
|
||||
string id;
|
||||
string name;
|
||||
|
|
@ -373,3 +405,54 @@ interface ClientEvent {
|
|||
Failed(string request_id, string error);
|
||||
Disconnected(string reason);
|
||||
};
|
||||
|
||||
[Enum]
|
||||
interface NativeRuntimeVerificationPolicyNative {
|
||||
RequireChecksum();
|
||||
RequireChecksumAndSignature();
|
||||
};
|
||||
|
||||
[Enum]
|
||||
interface NativeRuntimePruneModeNative {
|
||||
KeepActiveAndPrevious();
|
||||
ActiveOnly();
|
||||
};
|
||||
|
||||
dictionary NativeRuntimeInstallOptionsNative {
|
||||
string? mesh_version;
|
||||
string? skippy_abi_version;
|
||||
string selection;
|
||||
string? manifest_path;
|
||||
string? manifest_url;
|
||||
sequence<string> bundle_dirs;
|
||||
string? cache_dir;
|
||||
NativeRuntimeVerificationPolicyNative verification_policy;
|
||||
boolean allow_download;
|
||||
};
|
||||
|
||||
dictionary NativeRuntimeDownloadProgressNative {
|
||||
string native_runtime_id;
|
||||
string url;
|
||||
u64 downloaded_bytes;
|
||||
u64? total_bytes;
|
||||
boolean finished;
|
||||
};
|
||||
|
||||
dictionary InstalledNativeRuntimeNative {
|
||||
string mesh_version;
|
||||
string native_runtime_id;
|
||||
string flavor;
|
||||
string path;
|
||||
string? skippy_abi_version;
|
||||
};
|
||||
|
||||
dictionary NativeRuntimeInstallOutcomeNative {
|
||||
string status;
|
||||
InstalledNativeRuntimeNative runtime;
|
||||
string selected_native_runtime_id;
|
||||
string selected_source;
|
||||
};
|
||||
|
||||
dictionary NativeRuntimePruneResultNative {
|
||||
sequence<string> removed_dirs;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ fn node_stream_exports_compile() {
|
|||
|
||||
#[test]
|
||||
fn node_exports_compile() {
|
||||
let keypair = mesh_llm_api_server::OwnerKeypair::generate().to_hex();
|
||||
let keypair = mesh_llm_sdk::OwnerKeypair::generate().to_hex();
|
||||
let result = create_node(keypair, "valid-token".to_string(), None, None, false);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ fn ffi_client_runs_against_live_mesh() {
|
|||
let owner_keypair_hex = env::var("MESH_SDK_OWNER_KEYPAIR_HEX")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| mesh_llm_api_server::OwnerKeypair::generate().to_hex());
|
||||
.unwrap_or_else(|| mesh_llm_sdk::OwnerKeypair::generate().to_hex());
|
||||
let handle =
|
||||
create_node(owner_keypair_hex, invite_token, None, None, false).expect("create_node");
|
||||
handle.start().expect("start");
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex};
|
|||
use std::time::Duration;
|
||||
|
||||
fn valid_owner_keypair_hex() -> String {
|
||||
mesh_llm_api_server::OwnerKeypair::generate().to_hex()
|
||||
mesh_llm_sdk::OwnerKeypair::generate().to_hex()
|
||||
}
|
||||
|
||||
fn valid_node() -> Arc<meshllm_ffi::MeshNodeHandle> {
|
||||
|
|
@ -141,7 +141,7 @@ fn create_node_with_invalid_owner_keypair_fails() {
|
|||
#[test]
|
||||
fn create_node_uses_supplied_owner_keypair() {
|
||||
let owner_keypair_hex = {
|
||||
let keypair = mesh_llm_api_server::OwnerKeypair::generate();
|
||||
let keypair = mesh_llm_sdk::OwnerKeypair::generate();
|
||||
keypair.to_hex()
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ name = "mesh-llm-gpu-bench"
|
|||
version.workspace = true
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
license.workspace = true
|
||||
description = "Local GPU bandwidth benchmark helpers for mesh-llm"
|
||||
repository = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
homepage = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
|
|
|
|||
4
crates/mesh-llm-gpu-bench/README.md
Normal file
4
crates/mesh-llm-gpu-bench/README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# mesh-llm-gpu-bench
|
||||
|
||||
Local GPU bandwidth benchmark helpers used by mesh-llm hardware detection and
|
||||
runtime planning.
|
||||
|
|
@ -4,6 +4,9 @@ edition.workspace = true
|
|||
license.workspace = true
|
||||
version.workspace = true
|
||||
description = "Reusable guardrail and compaction primitives for mesh-llm OpenAI-compatible paths"
|
||||
repository = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
homepage = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
readme = "README.md"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
4
crates/mesh-llm-guardrails/README.md
Normal file
4
crates/mesh-llm-guardrails/README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# mesh-llm-guardrails
|
||||
|
||||
Reusable guardrail and compaction primitives shared by mesh-llm OpenAI-compatible
|
||||
request paths.
|
||||
16
crates/mesh-llm-hardware-profile/Cargo.toml
Normal file
16
crates/mesh-llm-hardware-profile/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "mesh-llm-hardware-profile"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
version.workspace = true
|
||||
description = "Host hardware profile detection for Mesh LLM native runtime selection"
|
||||
repository = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
homepage = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
readme = "README.md"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.68.0" }
|
||||
|
||||
10
crates/mesh-llm-hardware-profile/README.md
Normal file
10
crates/mesh-llm-hardware-profile/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# mesh-llm-hardware-profile
|
||||
|
||||
`mesh-llm-hardware-profile` detects the local operating system, architecture,
|
||||
GPU labels, and compatible native runtime flavors used by Mesh LLM native
|
||||
runtime selection.
|
||||
|
||||
The crate is intentionally small and publishable. It avoids depending on the
|
||||
host application runtime so the SDK, installer, updater, and CLI can share the
|
||||
same flavor ranking input without pulling in the full Mesh LLM app graph.
|
||||
|
||||
328
crates/mesh-llm-hardware-profile/src/lib.rs
Normal file
328
crates/mesh-llm-hardware-profile/src/lib.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
use mesh_llm_native_runtime::{
|
||||
HostCudaProfile, HostGpuProfile, HostRocmProfile, HostRuntimeProfile, HostVulkanProfile,
|
||||
NativeRuntimeBackendKind,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn host_runtime_profile() -> HostRuntimeProfile {
|
||||
let mut gpus = detect_gpus();
|
||||
apply_gpu_arch_overrides(&mut gpus);
|
||||
let cuda = detect_cuda_profile(&gpus);
|
||||
let rocm = detect_rocm_profile(&gpus);
|
||||
let vulkan = detect_vulkan_profile();
|
||||
HostRuntimeProfile {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
arch: std::env::consts::ARCH.to_string(),
|
||||
target_triple: option_env!("TARGET").map(str::to_string),
|
||||
available_flavors: detected_native_runtime_flavors(
|
||||
&gpus,
|
||||
cuda.as_ref(),
|
||||
rocm.as_ref(),
|
||||
vulkan.as_ref(),
|
||||
),
|
||||
gpus,
|
||||
cuda,
|
||||
rocm,
|
||||
vulkan,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detected_native_runtime_flavors(
|
||||
gpus: &[HostGpuProfile],
|
||||
cuda: Option<&HostCudaProfile>,
|
||||
rocm: Option<&HostRocmProfile>,
|
||||
vulkan: Option<&HostVulkanProfile>,
|
||||
) -> BTreeSet<NativeRuntimeBackendKind> {
|
||||
let mut flavors = BTreeSet::from([NativeRuntimeBackendKind::Cpu]);
|
||||
if cfg!(target_os = "macos") {
|
||||
flavors.insert(NativeRuntimeBackendKind::Metal);
|
||||
}
|
||||
if cuda.is_some() {
|
||||
flavors.insert(NativeRuntimeBackendKind::Cuda);
|
||||
}
|
||||
if rocm.is_some() {
|
||||
flavors.insert(NativeRuntimeBackendKind::Rocm);
|
||||
}
|
||||
if vulkan.is_some() {
|
||||
flavors.insert(NativeRuntimeBackendKind::Vulkan);
|
||||
}
|
||||
for gpu in gpus {
|
||||
insert_label_flavors(&mut flavors, &gpu.display_name);
|
||||
if let Some(device) = &gpu.backend_device {
|
||||
insert_label_flavors(&mut flavors, device);
|
||||
}
|
||||
}
|
||||
flavors
|
||||
}
|
||||
|
||||
fn detect_gpus() -> Vec<HostGpuProfile> {
|
||||
let labels = gpu_labels();
|
||||
labels
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, label)| HostGpuProfile {
|
||||
display_name: label,
|
||||
backend_device: None,
|
||||
stable_id: Some(format!("detected-{index}")),
|
||||
vram_bytes: None,
|
||||
unified_memory: cfg!(target_os = "macos"),
|
||||
cuda_sm: None,
|
||||
rocm_gfx: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn detect_cuda_profile(gpus: &[HostGpuProfile]) -> Option<HostCudaProfile> {
|
||||
let mut toolkit_majors = env_u32_set("MESH_LLM_CUDA_TOOLKIT_MAJORS");
|
||||
if let Some(major) = env_u32("MESH_LLM_CUDA_TOOLKIT_MAJOR") {
|
||||
toolkit_majors.insert(major);
|
||||
}
|
||||
if toolkit_majors.is_empty() {
|
||||
toolkit_majors.extend(cuda_majors_from_nvidia_smi());
|
||||
}
|
||||
let mut gpu_arches = env_string_set("MESH_LLM_CUDA_GPU_ARCHES");
|
||||
gpu_arches.extend(gpus.iter().filter_map(|gpu| gpu.cuda_sm.clone()));
|
||||
let has_cuda_label = gpus.iter().any(|gpu| {
|
||||
let label = gpu.display_name.to_ascii_lowercase();
|
||||
label.contains("nvidia") || label.contains("cuda")
|
||||
});
|
||||
if toolkit_majors.is_empty() && gpu_arches.is_empty() && !has_cuda_label {
|
||||
return None;
|
||||
}
|
||||
Some(HostCudaProfile {
|
||||
toolkit_majors,
|
||||
driver_version: std::env::var("MESH_LLM_CUDA_DRIVER_VERSION").ok(),
|
||||
gpu_arches,
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_rocm_profile(gpus: &[HostGpuProfile]) -> Option<HostRocmProfile> {
|
||||
let mut gpu_arches = env_string_set("MESH_LLM_ROCM_GPU_ARCHES");
|
||||
gpu_arches.extend(gpus.iter().filter_map(|gpu| gpu.rocm_gfx.clone()));
|
||||
let version = std::env::var("MESH_LLM_ROCM_VERSION").ok();
|
||||
let has_rocm_label = gpus.iter().any(|gpu| {
|
||||
let label = gpu.display_name.to_ascii_lowercase();
|
||||
label.contains("amd") || label.contains("radeon") || label.contains("rocm")
|
||||
});
|
||||
if gpu_arches.is_empty() && version.is_none() && !has_rocm_label {
|
||||
return None;
|
||||
}
|
||||
Some(HostRocmProfile {
|
||||
version,
|
||||
gpu_arches,
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_vulkan_profile() -> Option<HostVulkanProfile> {
|
||||
let api_version = std::env::var("MESH_LLM_VULKAN_API_VERSION").ok();
|
||||
let enabled = std::env::var("MESH_LLM_VULKAN_AVAILABLE")
|
||||
.ok()
|
||||
.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"));
|
||||
if enabled || api_version.is_some() || command_output("vulkaninfo", &["--summary"]).is_some() {
|
||||
return Some(HostVulkanProfile { api_version });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn apply_gpu_arch_overrides(gpus: &mut [HostGpuProfile]) {
|
||||
let cuda_arches = env_string_vec("MESH_LLM_CUDA_GPU_ARCHES");
|
||||
let rocm_arches = env_string_vec("MESH_LLM_ROCM_GPU_ARCHES");
|
||||
for (index, gpu) in gpus.iter_mut().enumerate() {
|
||||
gpu.cuda_sm = cuda_arches.get(index).cloned();
|
||||
gpu.rocm_gfx = rocm_arches.get(index).cloned();
|
||||
}
|
||||
}
|
||||
|
||||
fn cuda_majors_from_nvidia_smi() -> BTreeSet<u32> {
|
||||
let mut majors = BTreeSet::new();
|
||||
let Some(output) = command_output("nvidia-smi", &[]) else {
|
||||
return majors;
|
||||
};
|
||||
for token in output.split_whitespace() {
|
||||
if let Some(major) = cuda_major_from_token(token) {
|
||||
majors.insert(major);
|
||||
}
|
||||
}
|
||||
majors
|
||||
}
|
||||
|
||||
fn cuda_major_from_token(token: &str) -> Option<u32> {
|
||||
token
|
||||
.strip_prefix("CUDA")?
|
||||
.trim_start_matches("Version:")
|
||||
.trim_matches(|ch: char| !ch.is_ascii_digit())
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
}
|
||||
|
||||
fn gpu_labels() -> Vec<String> {
|
||||
let mut labels = Vec::new();
|
||||
append_command_lines(&mut labels, "nvidia-smi", &["-L"]);
|
||||
append_command_lines(&mut labels, "rocminfo", &[]);
|
||||
append_command_lines(&mut labels, "vulkaninfo", &["--summary"]);
|
||||
append_platform_gpu_labels(&mut labels);
|
||||
labels.sort();
|
||||
labels.dedup();
|
||||
labels
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn append_platform_gpu_labels(labels: &mut Vec<String>) {
|
||||
append_command_lines(labels, "lspci", &[]);
|
||||
append_linux_nvidia_proc_labels(labels);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn append_linux_nvidia_proc_labels(labels: &mut Vec<String>) {
|
||||
let Ok(entries) = std::fs::read_dir("/proc/driver/nvidia/gpus") else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path().join("information");
|
||||
let Ok(info) = std::fs::read_to_string(path) else {
|
||||
continue;
|
||||
};
|
||||
labels.extend(info.lines().map(str::to_string));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn append_platform_gpu_labels(labels: &mut Vec<String>) {
|
||||
append_command_lines(
|
||||
labels,
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn append_platform_gpu_labels(labels: &mut Vec<String>) {
|
||||
append_command_lines(labels, "system_profiler", &["SPDisplaysDataType"]);
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||
fn append_platform_gpu_labels(_labels: &mut Vec<String>) {}
|
||||
|
||||
fn append_command_lines(labels: &mut Vec<String>, program: &str, args: &[&str]) {
|
||||
let Some(output) = command_output(program, args) else {
|
||||
return;
|
||||
};
|
||||
labels.extend(
|
||||
output
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| looks_like_gpu_label(line))
|
||||
.map(str::to_string),
|
||||
);
|
||||
}
|
||||
|
||||
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new(program).args(args).output().ok()?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8(output.stdout).ok())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn looks_like_gpu_label(line: &str) -> bool {
|
||||
let label = line.to_ascii_lowercase();
|
||||
label.contains("gpu")
|
||||
|| label.contains("nvidia")
|
||||
|| label.contains("cuda")
|
||||
|| label.contains("amd")
|
||||
|| label.contains("radeon")
|
||||
|| label.contains("rocm")
|
||||
|| label.contains("vulkan")
|
||||
|| label.contains("metal")
|
||||
}
|
||||
|
||||
fn insert_label_flavors(flavors: &mut BTreeSet<NativeRuntimeBackendKind>, label: &str) {
|
||||
let label = label.to_ascii_lowercase();
|
||||
if label.contains("cuda") || label.contains("nvidia") {
|
||||
flavors.insert(NativeRuntimeBackendKind::Cuda);
|
||||
}
|
||||
if label.contains("rocm")
|
||||
|| label.contains("hip")
|
||||
|| label.contains("amd")
|
||||
|| label.contains("radeon")
|
||||
{
|
||||
flavors.insert(NativeRuntimeBackendKind::Rocm);
|
||||
}
|
||||
if label.contains("vulkan") {
|
||||
flavors.insert(NativeRuntimeBackendKind::Vulkan);
|
||||
}
|
||||
}
|
||||
|
||||
fn env_u32(name: &str) -> Option<u32> {
|
||||
std::env::var(name).ok()?.parse().ok()
|
||||
}
|
||||
|
||||
fn env_u32_set(name: &str) -> BTreeSet<u32> {
|
||||
env_string_vec(name)
|
||||
.into_iter()
|
||||
.filter_map(|value| value.parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn env_string_set(name: &str) -> BTreeSet<String> {
|
||||
env_string_vec(name).into_iter().collect()
|
||||
}
|
||||
|
||||
fn env_string_vec(name: &str) -> Vec<String> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|value| {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile(label: &str) -> HostGpuProfile {
|
||||
HostGpuProfile {
|
||||
display_name: label.to_string(),
|
||||
backend_device: None,
|
||||
stable_id: None,
|
||||
vram_bytes: None,
|
||||
unified_memory: false,
|
||||
cuda_sm: None,
|
||||
rocm_gfx: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_labels_enable_cuda() {
|
||||
let flavors = detected_native_runtime_flavors(
|
||||
&[profile("NVIDIA GeForce RTX 4090")],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(flavors.contains(&NativeRuntimeBackendKind::Cpu));
|
||||
assert!(flavors.contains(&NativeRuntimeBackendKind::Cuda));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amd_labels_enable_rocm() {
|
||||
let flavors =
|
||||
detected_native_runtime_flavors(&[profile("AMD Radeon PRO W7900")], None, None, None);
|
||||
|
||||
assert!(flavors.contains(&NativeRuntimeBackendKind::Rocm));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,11 @@
|
|||
name = "mesh-llm-host-runtime"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
license.workspace = true
|
||||
description = "Host runtime orchestration for mesh-llm nodes"
|
||||
repository = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
homepage = "https://github.com/Mesh-LLM/mesh-llm"
|
||||
readme = "README.md"
|
||||
|
||||
[features]
|
||||
default = ["web-ui"]
|
||||
|
|
@ -12,37 +17,45 @@ web-ui = ["mesh-llm-ui/embed-assets"]
|
|||
gpu-bench-cuda = ["mesh-llm-system/gpu-bench-cuda"]
|
||||
gpu-bench-hip = ["mesh-llm-system/gpu-bench-hip"]
|
||||
gpu-bench-intel = ["mesh-llm-system/gpu-bench-intel"]
|
||||
dynamic-native-runtime = [
|
||||
"mesh-llm-system/dynamic-native-runtime",
|
||||
"skippy-runtime/dynamic-native-runtime",
|
||||
"skippy-server/dynamic-native-runtime",
|
||||
]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
ansi-to-tui = "8"
|
||||
bytes = "1"
|
||||
mesh-mixture-of-agents = { path = "../mesh-mixture-of-agents" }
|
||||
mesh-llm-config = { path = "../mesh-llm-config" }
|
||||
mesh-llm-plugin = { path = "../mesh-llm-plugin" }
|
||||
mesh-llm-plugin-manager = { path = "../mesh-llm-plugin-manager" }
|
||||
mesh-llm-identity = { path = "../mesh-llm-identity" }
|
||||
mesh-llm-guardrails = { path = "../mesh-llm-guardrails" }
|
||||
mesh-llm-protocol = { path = "../mesh-llm-protocol" }
|
||||
mesh-llm-routing = { path = "../mesh-llm-routing" }
|
||||
mesh-llm-system = { path = "../mesh-llm-system", features = ["skippy-devices"] }
|
||||
mesh-llm-types = { path = "../mesh-llm-types" }
|
||||
mesh-llm-ui = { path = "../mesh-llm-ui", default-features = false }
|
||||
mesh-llm-node = { path = "../mesh-llm-node" }
|
||||
mesh-llm-api-server = { path = "../mesh-llm-api-server" }
|
||||
mesh-client = { package = "mesh-llm-client", path = "../mesh-client", features = ["host-io"] }
|
||||
model-artifact = { path = "../model-artifact" }
|
||||
model-package = { path = "../model-package" }
|
||||
model-ref = { path = "../model-ref" }
|
||||
model-resolver = { path = "../model-resolver" }
|
||||
openai-frontend = { path = "../openai-frontend" }
|
||||
skippy-protocol = { path = "../skippy-protocol" }
|
||||
skippy-coordinator = { path = "../skippy-coordinator" }
|
||||
skippy-runtime = { path = "../skippy-runtime" }
|
||||
skippy-server = { path = "../skippy-server" }
|
||||
skippy-topology = { path = "../skippy-topology" }
|
||||
mesh-mixture-of-agents = { path = "../mesh-mixture-of-agents", version = "0.68.0" }
|
||||
mesh-llm-config = { path = "../mesh-llm-config", version = "0.68.0" }
|
||||
mesh-llm-events = { path = "../mesh-llm-events", version = "0.68.0" }
|
||||
mesh-llm-plugin = { path = "../mesh-llm-plugin", version = "0.68.0" }
|
||||
mesh-llm-plugin-manager = { path = "../mesh-llm-plugin-manager", version = "0.68.0" }
|
||||
mesh-llm-identity = { path = "../mesh-llm-identity", version = "0.68.0", features = ["host-io"] }
|
||||
mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.68.0" }
|
||||
mesh-llm-runtime-install = { path = "../mesh-llm-runtime-install", version = "0.68.0" }
|
||||
mesh-llm-guardrails = { path = "../mesh-llm-guardrails", version = "0.68.0" }
|
||||
mesh-llm-protocol = { path = "../mesh-llm-protocol", version = "0.68.0" }
|
||||
mesh-llm-routing = { path = "../mesh-llm-routing", version = "0.68.0" }
|
||||
mesh-llm-system = { path = "../mesh-llm-system", version = "0.68.0", features = ["skippy-devices"] }
|
||||
mesh-llm-types = { path = "../mesh-llm-types", version = "0.68.0" }
|
||||
mesh-llm-ui = { path = "../mesh-llm-ui", version = "0.68.0", default-features = false }
|
||||
mesh-llm-node = { path = "../mesh-llm-node", version = "0.68.0" }
|
||||
mesh-llm-api-server = { path = "../mesh-llm-api-server", version = "0.68.0" }
|
||||
mesh-client = { package = "mesh-llm-client", path = "../mesh-client", version = "0.68.0", features = ["host-io"] }
|
||||
model-artifact = { path = "../model-artifact", version = "0.68.0" }
|
||||
model-hf = { path = "../model-hf", version = "0.68.0" }
|
||||
model-package = { path = "../model-package", version = "0.68.0" }
|
||||
model-ref = { path = "../model-ref", version = "0.68.0" }
|
||||
model-resolver = { path = "../model-resolver", version = "0.68.0" }
|
||||
openai-frontend = { path = "../openai-frontend", version = "0.68.0" }
|
||||
skippy-protocol = { path = "../skippy-protocol", version = "0.68.0" }
|
||||
skippy-coordinator = { path = "../skippy-coordinator", version = "0.68.0" }
|
||||
skippy-runtime = { path = "../skippy-runtime", version = "0.68.0" }
|
||||
skippy-server = { path = "../skippy-server", version = "0.68.0" }
|
||||
skippy-topology = { path = "../skippy-topology", version = "0.68.0" }
|
||||
iroh = "1.0.0-rc.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
|
@ -53,7 +66,6 @@ serde_json = "1"
|
|||
serde_yaml = "0.9"
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
arboard = "3"
|
||||
rand = "0.10"
|
||||
base64 = "0.22"
|
||||
dirs = "6.0.0"
|
||||
|
|
@ -65,8 +77,11 @@ opentelemetry_sdk = { version = "0.31.0", default-features = false, features = [
|
|||
opentelemetry-otlp = { version = "0.31.0", default-features = false, features = ["metrics", "http-proto", "reqwest-blocking-client"] }
|
||||
rustls = "0.23.36"
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
flate2 = "1"
|
||||
futures-util = "0.3"
|
||||
semver = "1"
|
||||
sha2 = "0.10"
|
||||
tar = "0.4"
|
||||
ed25519-dalek = { version = "=3.0.0-pre.7", features = ["rand_core"] }
|
||||
crypto_box = "0.9"
|
||||
chacha20poly1305 = "0.10"
|
||||
|
|
@ -81,7 +96,6 @@ http = "1"
|
|||
http-body-util = "0.1"
|
||||
tokio-stream = "0.1"
|
||||
crossterm = "0.28"
|
||||
ratatui = "0.30"
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
libc = "0.2.183"
|
||||
|
|
@ -95,15 +109,14 @@ toml = "0.9"
|
|||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
hf_hub = { package = "hf-hub", version = "1.0.0-rc.1", default-features = false, features = ["blocking"] }
|
||||
tabwriter = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = "3"
|
||||
mesh-client = { package = "mesh-llm-client", path = "../mesh-client" }
|
||||
tempfile = "3"
|
||||
mesh-client = { package = "mesh-llm-client", path = "../mesh-client", version = "0.68.0" }
|
||||
# Used by the gated-relay regression test to spawn an in-process iroh-relay
|
||||
# with AccessConfig::Restricted, then build a real iroh::Endpoint from our
|
||||
# relay_map_from_urls output and verify --relay-auth tokens reach the relay
|
||||
# on the WebSocket upgrade (and that the wrong token is rejected).
|
||||
iroh = { version = "1.0.0-rc.0", features = ["test-utils"] }
|
||||
iroh-relay = { version = "1.0.0-rc.0", features = ["server", "test-utils"] }
|
||||
futures-util = "0.3"
|
||||
|
|
|
|||
|
|
@ -1,23 +1,9 @@
|
|||
# mesh-llm-host-runtime
|
||||
|
||||
`mesh-llm-host-runtime` owns the host runtime implementation behind the
|
||||
`mesh-llm` binary.
|
||||
`mesh-llm-host-runtime` composes the host-side mesh node runtime. It wires
|
||||
model resolution, local serving, discovery, networking, runtime state, plugins,
|
||||
the management API, and the shipped CLI entrypoint used by the `mesh-llm`
|
||||
binary.
|
||||
|
||||
This crate includes the remaining host-only subsystems that coordinate a local
|
||||
node:
|
||||
|
||||
- CLI parsing, command handlers, terminal output, and dashboard rendering
|
||||
- management API routes and OpenAI-compatible ingress wiring
|
||||
- mesh node orchestration, gossip integration, peer state, and host routing glue
|
||||
- embedded skippy runtime coordination and stage deployment
|
||||
- host-side plugin runtime, MCP bridge, and built-in plugins
|
||||
- runtime-data aggregation and API view shaping
|
||||
- host-local model catalog, inventory, and download flows that still coordinate
|
||||
CLI/runtime behavior
|
||||
|
||||
The package named `mesh-llm` is now the app assembly crate: it exposes the
|
||||
binary entrypoint and re-exports this runtime crate for compatibility. Keep new
|
||||
pure/shared ownership in narrower crates such as `mesh-llm-types`,
|
||||
`mesh-llm-protocol`, `mesh-llm-routing`, `mesh-llm-identity`,
|
||||
`mesh-llm-system`, `mesh-llm-ui`, `model-*`, `openai-frontend`, and
|
||||
`skippy-*`.
|
||||
This crate is being split so reusable CLI, TUI, SDK, and embeddable runtime
|
||||
surfaces can be published and consumed independently.
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ impl Serialize for PublicationState {
|
|||
}
|
||||
|
||||
pub enum RuntimeControlRequest {
|
||||
Join {
|
||||
invite_token: String,
|
||||
resp: tokio::sync::oneshot::Sender<anyhow::Result<()>>,
|
||||
},
|
||||
Load {
|
||||
spec: String,
|
||||
resp: tokio::sync::oneshot::Sender<anyhow::Result<RuntimeLoadResponse>>,
|
||||
|
|
@ -56,7 +60,9 @@ pub enum RuntimeControlRequest {
|
|||
mode: GuardrailMode,
|
||||
resp: tokio::sync::oneshot::Sender<anyhow::Result<OpenAiGuardrailModeUpdateResponse>>,
|
||||
},
|
||||
Shutdown,
|
||||
Shutdown {
|
||||
source: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,38 +0,0 @@
|
|||
use anyhow::Result;
|
||||
|
||||
pub(crate) async fn dispatch_download_command(name: Option<&str>, draft: bool) -> Result<()> {
|
||||
match name {
|
||||
Some(query) => {
|
||||
let model_ref = crate::models::find_remote_catalog_model_exact(query)
|
||||
.map(|model| crate::models::remote_catalog_model_ref(&model))
|
||||
.unwrap_or_else(|| query.to_string());
|
||||
let (_path, details) =
|
||||
crate::models::download_model_ref_with_progress_details(&model_ref, true).await?;
|
||||
if draft {
|
||||
if let Some(draft_name) = details
|
||||
.as_ref()
|
||||
.and_then(|details| details.draft.as_deref())
|
||||
{
|
||||
let draft_ref = crate::models::find_remote_catalog_model_exact(draft_name)
|
||||
.map(|model| crate::models::remote_catalog_model_ref(&model))
|
||||
.unwrap_or_else(|| draft_name.to_string());
|
||||
crate::models::download_model_ref_with_progress_details(&draft_ref, true)
|
||||
.await?;
|
||||
} else {
|
||||
eprintln!("⚠ No draft model available for {}", query);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
crate::models::remote_catalog::ensure_catalog()?;
|
||||
eprintln!("Available models:");
|
||||
eprintln!();
|
||||
for model in crate::models::remote_catalog::loaded_models()? {
|
||||
let size = model.size.as_deref().unwrap_or("?");
|
||||
let description = model.description.as_deref().unwrap_or("");
|
||||
eprintln!(" {:40} {:>6} {}", model.name, size, description);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
mod agent_cli;
|
||||
mod auth;
|
||||
mod benchmark;
|
||||
mod discover;
|
||||
mod doctor;
|
||||
mod download;
|
||||
mod gpus;
|
||||
mod model_package;
|
||||
mod models;
|
||||
mod plugin;
|
||||
mod plugin_cli;
|
||||
mod runtime;
|
||||
mod skills;
|
||||
mod update;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::cli::commands::agent_cli::{run_claude, run_goose, run_opencode, run_pi};
|
||||
use crate::cli::commands::benchmark::dispatch_benchmark_command;
|
||||
use crate::cli::commands::discover::{DiscoverOptions, run_discover, run_stop};
|
||||
use crate::cli::commands::doctor::dispatch_doctor_command;
|
||||
use crate::cli::commands::download::dispatch_download_command;
|
||||
use crate::cli::commands::gpus::dispatch_gpu_command;
|
||||
use crate::cli::commands::models::dispatch_models_command;
|
||||
use crate::cli::commands::plugin::run_plugin_command;
|
||||
use crate::cli::commands::plugin_cli::run_external_plugin_command;
|
||||
use crate::cli::commands::runtime::{dispatch_runtime_command, run_drop, run_load, run_status};
|
||||
use crate::cli::commands::skills::run_skills_command;
|
||||
use crate::cli::commands::update::run_update;
|
||||
use crate::cli::{AuthCommand, Cli, Command};
|
||||
use crate::network::nostr;
|
||||
|
||||
pub(crate) async fn dispatch(cli: &Cli) -> Result<bool> {
|
||||
let Some(cmd) = cli.command.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
dispatch_command(cli, cmd).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn dispatch_command(cli: &Cli, cmd: &Command) -> Result<()> {
|
||||
match cmd {
|
||||
Command::Auth { command } => dispatch_auth_command(command),
|
||||
Command::ModelPrepare { .. } => dispatch_model_prepare(cmd).await,
|
||||
_ => dispatch_general_command(cli, cmd).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_general_command(cli: &Cli, cmd: &Command) -> Result<()> {
|
||||
match cmd {
|
||||
Command::Models { command } => {
|
||||
dispatch_models_command(command).await?;
|
||||
Ok(())
|
||||
}
|
||||
Command::Download { name, draft } => {
|
||||
dispatch_download_command(name.as_deref(), *draft).await
|
||||
}
|
||||
Command::Update { .. } => run_update(cli).await,
|
||||
Command::Gpus { json, command } => {
|
||||
dispatch_gpu_command(*json, command.as_ref())?;
|
||||
Ok(())
|
||||
}
|
||||
Command::Runtime { command } => dispatch_runtime_command(command.as_ref()).await,
|
||||
Command::Doctor { command } => dispatch_doctor_command(command).await,
|
||||
Command::Load { name, port } => run_load(name, *port).await,
|
||||
Command::Unload { name, port } => run_drop(name, *port).await,
|
||||
Command::Status { port } => run_status(*port).await,
|
||||
Command::Stop => run_stop(),
|
||||
Command::Discover {
|
||||
name,
|
||||
model,
|
||||
min_vram,
|
||||
region,
|
||||
auto,
|
||||
relay,
|
||||
} => {
|
||||
run_discover(DiscoverOptions {
|
||||
name: name.clone(),
|
||||
model: model.clone(),
|
||||
min_vram_gb: *min_vram,
|
||||
region: region.clone(),
|
||||
auto_join: *auto,
|
||||
relays: relay.clone(),
|
||||
discovery_mode: cli.mesh_discovery_mode,
|
||||
supplied_join_tokens: cli.join.clone(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
Command::RotateKey => nostr::rotate_keys(),
|
||||
Command::Goose { model, port } => run_goose(model.clone(), *port).await,
|
||||
Command::Claude { model, port } => run_claude(model.clone(), *port).await,
|
||||
Command::Pi { model, host, write } => run_pi(model.clone(), host, *write).await,
|
||||
Command::Opencode { model, host, write } => run_opencode(model.clone(), host, *write).await,
|
||||
Command::Skills { command } => run_skills_command(command),
|
||||
Command::Plugin { command } => run_plugin_command(command, cli).await,
|
||||
Command::Benchmark { command } => dispatch_benchmark_command(command).await,
|
||||
Command::ModelPrepare { .. } => dispatch_model_prepare(cmd).await,
|
||||
Command::Auth { command } => dispatch_auth_command(command),
|
||||
Command::ExternalPlugin(args) => run_external_plugin_command(cli, args).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_model_prepare(cmd: &Command) -> Result<()> {
|
||||
let Command::ModelPrepare {
|
||||
source_repo,
|
||||
quant,
|
||||
target,
|
||||
model_id,
|
||||
flavor,
|
||||
timeout,
|
||||
mesh_llm_ref,
|
||||
dry_run,
|
||||
confirm,
|
||||
follow,
|
||||
json,
|
||||
status,
|
||||
logs,
|
||||
cancel,
|
||||
list,
|
||||
update_script,
|
||||
} = cmd
|
||||
else {
|
||||
unreachable!("dispatch_model_prepare called for non-model-prepare command");
|
||||
};
|
||||
|
||||
model_package::dispatch_model_package(model_package::ModelPrepareArgs {
|
||||
source_repo: source_repo.as_deref(),
|
||||
quant: quant.as_deref(),
|
||||
target: target.as_deref(),
|
||||
model_id: model_id.as_deref(),
|
||||
flavor,
|
||||
timeout,
|
||||
mesh_llm_ref,
|
||||
dry_run: *dry_run,
|
||||
confirm: *confirm,
|
||||
follow: *follow,
|
||||
json: *json,
|
||||
status: status.as_deref(),
|
||||
logs: logs.as_deref(),
|
||||
cancel: cancel.as_deref(),
|
||||
list: *list,
|
||||
update_script: *update_script,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn dispatch_auth_command(command: &AuthCommand) -> Result<()> {
|
||||
match command {
|
||||
AuthCommand::Init {
|
||||
owner_key,
|
||||
force,
|
||||
no_passphrase,
|
||||
keychain,
|
||||
} => auth::run_init(owner_key.clone(), *force, *no_passphrase, *keychain),
|
||||
AuthCommand::Status {
|
||||
owner_key,
|
||||
node_key,
|
||||
node_ownership,
|
||||
trust_store,
|
||||
} => auth::run_status(
|
||||
owner_key.clone(),
|
||||
node_key.clone(),
|
||||
node_ownership.clone(),
|
||||
trust_store.clone(),
|
||||
),
|
||||
AuthCommand::SignNode {
|
||||
owner_key,
|
||||
node_key,
|
||||
out,
|
||||
hostname_hint,
|
||||
node_label,
|
||||
expires_in_hours,
|
||||
} => auth::run_sign_node(
|
||||
owner_key.clone(),
|
||||
node_key.clone(),
|
||||
out.clone(),
|
||||
node_label.clone(),
|
||||
hostname_hint.clone(),
|
||||
*expires_in_hours,
|
||||
),
|
||||
AuthCommand::RenewNode {
|
||||
owner_key,
|
||||
node_key,
|
||||
out,
|
||||
hostname_hint,
|
||||
node_label,
|
||||
expires_in_hours,
|
||||
} => auth::run_renew_node(
|
||||
owner_key.clone(),
|
||||
node_key.clone(),
|
||||
out.clone(),
|
||||
node_label.clone(),
|
||||
hostname_hint.clone(),
|
||||
*expires_in_hours,
|
||||
),
|
||||
AuthCommand::VerifyNode {
|
||||
file,
|
||||
node_id,
|
||||
trust_store,
|
||||
trust_policy,
|
||||
} => auth::run_verify_node(
|
||||
file.clone(),
|
||||
node_id.clone(),
|
||||
trust_store.clone(),
|
||||
*trust_policy,
|
||||
),
|
||||
AuthCommand::RotateNode {
|
||||
owner_key,
|
||||
node_key,
|
||||
out,
|
||||
hostname_hint,
|
||||
node_label,
|
||||
expires_in_hours,
|
||||
revoke_current,
|
||||
reason,
|
||||
trust_store,
|
||||
} => auth::run_rotate_node(
|
||||
owner_key.clone(),
|
||||
node_key.clone(),
|
||||
out.clone(),
|
||||
node_label.clone(),
|
||||
hostname_hint.clone(),
|
||||
*expires_in_hours,
|
||||
*revoke_current,
|
||||
reason.clone(),
|
||||
trust_store.clone(),
|
||||
),
|
||||
AuthCommand::RevokeOwner {
|
||||
owner_id,
|
||||
reason,
|
||||
trust_store,
|
||||
} => auth::run_revoke_owner(owner_id.clone(), reason.clone(), trust_store.clone()),
|
||||
AuthCommand::RevokeNode {
|
||||
cert_id,
|
||||
node_id,
|
||||
reason,
|
||||
trust_store,
|
||||
} => auth::run_revoke_node(
|
||||
cert_id.clone(),
|
||||
node_id.clone(),
|
||||
reason.clone(),
|
||||
trust_store.clone(),
|
||||
),
|
||||
AuthCommand::RotateOwner {
|
||||
owner_key,
|
||||
no_passphrase,
|
||||
force,
|
||||
} => auth::run_rotate_owner(owner_key.clone(), *no_passphrase, *force),
|
||||
AuthCommand::Trust { command } => auth::run_trust_command(command),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
use anyhow::Result;
|
||||
|
||||
use crate::cli::Cli;
|
||||
use crate::cli::Command;
|
||||
use crate::system::autoupdate;
|
||||
|
||||
pub async fn run_update(cli: &Cli) -> Result<()> {
|
||||
let (requested_version, flavor, detect_flavor) = match &cli.command {
|
||||
Some(Command::Update {
|
||||
version,
|
||||
flavor,
|
||||
detect_flavor,
|
||||
}) => (version.as_deref(), *flavor, *detect_flavor),
|
||||
_ => (None, None, false),
|
||||
};
|
||||
autoupdate::run_update_command(autoupdate::UpdateCommandOptions {
|
||||
flavor,
|
||||
detect_flavor,
|
||||
requested_version,
|
||||
current_version: crate::VERSION,
|
||||
})
|
||||
.await
|
||||
}
|
||||
65
crates/mesh-llm-host-runtime/src/command_support.rs
Normal file
65
crates/mesh-llm-host-runtime/src/command_support.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//! Public support API for the `mesh-llm` binary command handlers.
|
||||
//!
|
||||
//! This is intentionally not a command implementation module. It exposes the
|
||||
//! host-runtime operations the binary crate needs after CLI ownership moved out
|
||||
//! of host-runtime.
|
||||
|
||||
pub mod discovery {
|
||||
pub mod nostr {
|
||||
pub use crate::network::nostr::{
|
||||
DiscoveredMesh, MeshFilter, MeshListing, discover, rotate_keys, score_mesh,
|
||||
};
|
||||
}
|
||||
|
||||
pub use crate::discovery::{DiscoveryScope, MeshDiscoveryMode};
|
||||
pub use crate::mesh::load_last_mesh_id;
|
||||
pub use crate::network::discovery::{LAN_SERVICE_TYPE, LanDiscoveredMesh, discover_lan};
|
||||
pub use crate::runtime::instance::{
|
||||
RuntimeProcessTarget, collect_runtime_stop_targets, runtime_root,
|
||||
};
|
||||
pub use crate::runtime::nostr_relays;
|
||||
}
|
||||
|
||||
pub mod models {
|
||||
pub mod election {
|
||||
pub use crate::inference::election::total_model_bytes;
|
||||
}
|
||||
|
||||
pub mod skippy {
|
||||
pub use crate::inference::skippy::{
|
||||
CertificationGateStatus, SkippyCertificationRequest, certify_layer_package,
|
||||
identity_from_layer_package, is_layer_package_ref, materialized_stage_cache_dir,
|
||||
materialized_stages_for_sources, prune_unpinned_materialized_stages,
|
||||
remove_materialized_stages_for_sources, resolve_hf_package_to_local,
|
||||
};
|
||||
}
|
||||
|
||||
pub use crate::models::remote_catalog;
|
||||
pub use crate::models::{
|
||||
DeleteResult, ModelCapabilities, ModelCleanupPlan, ModelCleanupResult, ModelDetails,
|
||||
ResolvedModel, SearchArtifactFilter, SearchHit, SearchProgress, SearchSort,
|
||||
ShowVariantsProgress, delete, download_model_ref_with_progress_details,
|
||||
execute_model_cleanup, find_model_path, find_remote_catalog_model_exact,
|
||||
huggingface_hub_cache_dir, huggingface_identity_for_path, installed_model_capabilities,
|
||||
installed_model_display_name, installed_model_huggingface_ref,
|
||||
layered_package_layer_count_for_path, layered_package_total_bytes_for_path,
|
||||
load_model_usage_record_for_path, model_usage_cache_dir, plan_model_cleanup,
|
||||
remote_catalog_model_draft_ref, remote_catalog_model_ref, run_update,
|
||||
scan_installed_models, search_catalog_json_payload, search_catalog_models,
|
||||
search_huggingface, search_huggingface_json_payload, show_exact_model,
|
||||
show_model_variants_with_progress,
|
||||
};
|
||||
pub use crate::models::{capabilities, catalog};
|
||||
}
|
||||
|
||||
pub mod plugin {
|
||||
pub use crate::plugin::{
|
||||
ExternalPluginSpec, GpuAssignment, GpuConfig, MeshConfig, PluginHostMode, PluginManager,
|
||||
ResolvedPlugins, ToolCallResult, bundled_cli_plugin_spec, load_config,
|
||||
};
|
||||
pub use crate::runtime::load_resolved_plugins;
|
||||
}
|
||||
|
||||
pub mod runtime_instances {
|
||||
pub use crate::runtime::instance::{LocalInstanceSnapshot, runtime_root, scan_local_instances};
|
||||
}
|
||||
410
crates/mesh-llm-host-runtime/src/crypto/control_plane.rs
Normal file
410
crates/mesh-llm-host-runtime/src/crypto/control_plane.rs
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
use std::{error::Error, fmt};
|
||||
|
||||
use mesh_llm_identity::{
|
||||
NodeOwnershipClaim, OwnershipStatus, OwnershipSummary, SignedNodeOwnership, TrustPolicy,
|
||||
TrustStore, verify_node_ownership,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ControlPlaneAuthError {
|
||||
MissingLocalOwnerIdentity {
|
||||
local_status: OwnershipStatus,
|
||||
},
|
||||
MissingRemoteOwnerAttestation,
|
||||
RemoteOwnerMismatch {
|
||||
local_owner_id: String,
|
||||
remote_owner_id: String,
|
||||
},
|
||||
RemoteOwnershipInvalid {
|
||||
status: OwnershipStatus,
|
||||
owner_id: Option<String>,
|
||||
cert_id: Option<String>,
|
||||
},
|
||||
TargetNodeMismatch {
|
||||
expected_node_id: String,
|
||||
actual_node_id: String,
|
||||
},
|
||||
UnsupportedTrustPolicy {
|
||||
policy: TrustPolicy,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for ControlPlaneAuthError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingLocalOwnerIdentity { local_status } => {
|
||||
write!(f, "missing local owner identity ({local_status:?})")
|
||||
}
|
||||
Self::MissingRemoteOwnerAttestation => write!(f, "missing remote owner attestation"),
|
||||
Self::RemoteOwnerMismatch {
|
||||
local_owner_id,
|
||||
remote_owner_id,
|
||||
} => write!(
|
||||
f,
|
||||
"remote owner mismatch (local {local_owner_id}, remote {remote_owner_id})"
|
||||
),
|
||||
Self::RemoteOwnershipInvalid {
|
||||
status,
|
||||
owner_id,
|
||||
cert_id,
|
||||
} => write!(
|
||||
f,
|
||||
"remote ownership invalid ({status:?}, owner_id={}, cert_id={})",
|
||||
owner_id.as_deref().unwrap_or("unknown"),
|
||||
cert_id.as_deref().unwrap_or("unknown")
|
||||
),
|
||||
Self::TargetNodeMismatch {
|
||||
expected_node_id,
|
||||
actual_node_id,
|
||||
} => write!(
|
||||
f,
|
||||
"target node mismatch (expected {expected_node_id}, got {actual_node_id})"
|
||||
),
|
||||
Self::UnsupportedTrustPolicy { policy } => {
|
||||
write!(f, "unsupported control-plane trust policy {policy:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ControlPlaneAuthError {}
|
||||
|
||||
pub fn verify_control_plane_target_node(
|
||||
target_node_id: &[u8],
|
||||
actual_local_endpoint_id: &[u8; 32],
|
||||
) -> Result<(), ControlPlaneAuthError> {
|
||||
if target_node_id == actual_local_endpoint_id {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ControlPlaneAuthError::TargetNodeMismatch {
|
||||
expected_node_id: hex::encode(actual_local_endpoint_id),
|
||||
actual_node_id: hex::encode(target_node_id),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify_control_plane_peer_ownership(
|
||||
local_owner: &OwnershipSummary,
|
||||
remote_ownership: Option<&crate::proto::node::SignedNodeOwnership>,
|
||||
actual_remote_endpoint_id: &[u8; 32],
|
||||
trust_store: &TrustStore,
|
||||
trust_policy: TrustPolicy,
|
||||
now_unix_ms: u64,
|
||||
) -> Result<OwnershipSummary, ControlPlaneAuthError> {
|
||||
let Some(local_owner_id) = local_owner
|
||||
.owner_id
|
||||
.as_ref()
|
||||
.filter(|_| local_owner.verified)
|
||||
else {
|
||||
return Err(ControlPlaneAuthError::MissingLocalOwnerIdentity {
|
||||
local_status: local_owner.status.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
match trust_policy {
|
||||
TrustPolicy::Off | TrustPolicy::PreferOwned | TrustPolicy::RequireOwned => {}
|
||||
TrustPolicy::Allowlist => {
|
||||
return Err(ControlPlaneAuthError::UnsupportedTrustPolicy {
|
||||
policy: trust_policy,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let remote_ownership = remote_ownership
|
||||
.map(proto_signed_node_ownership_to_local)
|
||||
.ok_or(ControlPlaneAuthError::MissingRemoteOwnerAttestation)?;
|
||||
let remote_summary = verify_node_ownership(
|
||||
Some(&remote_ownership),
|
||||
actual_remote_endpoint_id,
|
||||
trust_store,
|
||||
TrustPolicy::Off,
|
||||
now_unix_ms,
|
||||
);
|
||||
|
||||
match remote_summary.status {
|
||||
OwnershipStatus::Verified => {
|
||||
let Some(remote_owner_id) = remote_summary.owner_id.as_ref() else {
|
||||
return Err(ControlPlaneAuthError::RemoteOwnershipInvalid {
|
||||
status: remote_summary.status.clone(),
|
||||
owner_id: None,
|
||||
cert_id: remote_summary.cert_id.clone(),
|
||||
});
|
||||
};
|
||||
if remote_owner_id != local_owner_id {
|
||||
return Err(ControlPlaneAuthError::RemoteOwnerMismatch {
|
||||
local_owner_id: local_owner_id.clone(),
|
||||
remote_owner_id: remote_owner_id.clone(),
|
||||
});
|
||||
}
|
||||
Ok(remote_summary)
|
||||
}
|
||||
OwnershipStatus::MismatchedNodeId => Err(ControlPlaneAuthError::TargetNodeMismatch {
|
||||
expected_node_id: hex::encode(actual_remote_endpoint_id),
|
||||
actual_node_id: remote_ownership.claim.node_endpoint_id,
|
||||
}),
|
||||
_ => Err(ControlPlaneAuthError::RemoteOwnershipInvalid {
|
||||
status: remote_summary.status.clone(),
|
||||
owner_id: remote_summary.owner_id.clone(),
|
||||
cert_id: remote_summary.cert_id.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn proto_signed_node_ownership_to_local(
|
||||
attestation: &crate::proto::node::SignedNodeOwnership,
|
||||
) -> SignedNodeOwnership {
|
||||
SignedNodeOwnership {
|
||||
claim: NodeOwnershipClaim {
|
||||
version: attestation.version,
|
||||
cert_id: attestation.cert_id.clone(),
|
||||
owner_id: attestation.owner_id.clone(),
|
||||
owner_sign_public_key: hex::encode(&attestation.owner_sign_public_key),
|
||||
node_endpoint_id: hex::encode(&attestation.node_endpoint_id),
|
||||
issued_at_unix_ms: attestation.issued_at_unix_ms,
|
||||
expires_at_unix_ms: attestation.expires_at_unix_ms,
|
||||
node_label: attestation.node_label.clone(),
|
||||
hostname_hint: attestation.hostname_hint.clone(),
|
||||
},
|
||||
signature: hex::encode(&attestation.signature),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mesh_llm_identity::{OwnerKeypair, sign_node_ownership};
|
||||
|
||||
fn current_time_unix_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn proto_signed_node_ownership(
|
||||
ownership: &SignedNodeOwnership,
|
||||
) -> crate::proto::node::SignedNodeOwnership {
|
||||
crate::proto::node::SignedNodeOwnership {
|
||||
version: ownership.claim.version,
|
||||
cert_id: ownership.claim.cert_id.clone(),
|
||||
owner_id: ownership.claim.owner_id.clone(),
|
||||
owner_sign_public_key: hex::decode(&ownership.claim.owner_sign_public_key)
|
||||
.expect("test owner_sign_public_key must decode"),
|
||||
node_endpoint_id: hex::decode(&ownership.claim.node_endpoint_id)
|
||||
.expect("test node_endpoint_id must decode"),
|
||||
issued_at_unix_ms: ownership.claim.issued_at_unix_ms,
|
||||
expires_at_unix_ms: ownership.claim.expires_at_unix_ms,
|
||||
node_label: ownership.claim.node_label.clone(),
|
||||
hostname_hint: ownership.claim.hostname_hint.clone(),
|
||||
signature: hex::decode(&ownership.signature).expect("test signature must decode"),
|
||||
}
|
||||
}
|
||||
|
||||
fn verified_local_owner_summary(owner: &OwnerKeypair) -> OwnershipSummary {
|
||||
OwnershipSummary {
|
||||
owner_id: Some(owner.owner_id()),
|
||||
status: OwnershipStatus::Verified,
|
||||
verified: true,
|
||||
..OwnershipSummary::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_plane_auth_same_owner_without_gossip() {
|
||||
let owner = OwnerKeypair::generate();
|
||||
let local_owner = verified_local_owner_summary(&owner);
|
||||
let remote_node_endpoint_id = [0x52; 32];
|
||||
let remote_ownership = sign_node_ownership(
|
||||
&owner,
|
||||
&remote_node_endpoint_id,
|
||||
current_time_unix_ms() + 60_000,
|
||||
Some("remote-worker".into()),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let summary = verify_control_plane_peer_ownership(
|
||||
&local_owner,
|
||||
Some(&proto_signed_node_ownership(&remote_ownership)),
|
||||
&remote_node_endpoint_id,
|
||||
&TrustStore::default(),
|
||||
TrustPolicy::Off,
|
||||
current_time_unix_ms(),
|
||||
)
|
||||
.expect("same-owner direct control attestation must succeed without gossip state");
|
||||
|
||||
assert!(summary.verified);
|
||||
assert_eq!(summary.owner_id.as_deref(), Some(owner.owner_id().as_str()));
|
||||
assert_eq!(summary.node_label.as_deref(), Some("remote-worker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_plane_auth_rejects_wrong_owner() {
|
||||
let local_owner = OwnerKeypair::generate();
|
||||
let remote_owner = OwnerKeypair::generate();
|
||||
let remote_node_endpoint_id = [0x62; 32];
|
||||
let remote_ownership = sign_node_ownership(
|
||||
&remote_owner,
|
||||
&remote_node_endpoint_id,
|
||||
current_time_unix_ms() + 60_000,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = verify_control_plane_peer_ownership(
|
||||
&verified_local_owner_summary(&local_owner),
|
||||
Some(&proto_signed_node_ownership(&remote_ownership)),
|
||||
&remote_node_endpoint_id,
|
||||
&TrustStore::default(),
|
||||
TrustPolicy::Off,
|
||||
current_time_unix_ms(),
|
||||
)
|
||||
.expect_err("different-owner control attestation must fail closed");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ControlPlaneAuthError::RemoteOwnerMismatch { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_plane_auth_rejects_wrong_node_id() {
|
||||
let owner = OwnerKeypair::generate();
|
||||
let local_owner = verified_local_owner_summary(&owner);
|
||||
let claimed_node_endpoint_id = [0x71; 32];
|
||||
let actual_remote_endpoint_id = [0x72; 32];
|
||||
let remote_ownership = sign_node_ownership(
|
||||
&owner,
|
||||
&claimed_node_endpoint_id,
|
||||
current_time_unix_ms() + 60_000,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = verify_control_plane_peer_ownership(
|
||||
&local_owner,
|
||||
Some(&proto_signed_node_ownership(&remote_ownership)),
|
||||
&actual_remote_endpoint_id,
|
||||
&TrustStore::default(),
|
||||
TrustPolicy::Off,
|
||||
current_time_unix_ms(),
|
||||
)
|
||||
.expect_err("wrong peer node id must fail closed");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ControlPlaneAuthError::TargetNodeMismatch { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_plane_auth_rejects_bad_signature() {
|
||||
let owner = OwnerKeypair::generate();
|
||||
let local_owner = verified_local_owner_summary(&owner);
|
||||
let remote_node_endpoint_id = [0x81; 32];
|
||||
let mut remote_ownership = proto_signed_node_ownership(
|
||||
&sign_node_ownership(
|
||||
&owner,
|
||||
&remote_node_endpoint_id,
|
||||
current_time_unix_ms() + 60_000,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
remote_ownership.signature[0] ^= 0xFF;
|
||||
|
||||
let err = verify_control_plane_peer_ownership(
|
||||
&local_owner,
|
||||
Some(&remote_ownership),
|
||||
&remote_node_endpoint_id,
|
||||
&TrustStore::default(),
|
||||
TrustPolicy::Off,
|
||||
current_time_unix_ms(),
|
||||
)
|
||||
.expect_err("bad-signature control attestation must fail closed");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ControlPlaneAuthError::RemoteOwnershipInvalid {
|
||||
status: OwnershipStatus::InvalidSignature,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_plane_auth_rejects_missing_local_owner_identity() {
|
||||
let owner = OwnerKeypair::generate();
|
||||
let remote_node_endpoint_id = [0x91; 32];
|
||||
let remote_ownership = sign_node_ownership(
|
||||
&owner,
|
||||
&remote_node_endpoint_id,
|
||||
current_time_unix_ms() + 60_000,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = verify_control_plane_peer_ownership(
|
||||
&OwnershipSummary::default(),
|
||||
Some(&proto_signed_node_ownership(&remote_ownership)),
|
||||
&remote_node_endpoint_id,
|
||||
&TrustStore::default(),
|
||||
TrustPolicy::Off,
|
||||
current_time_unix_ms(),
|
||||
)
|
||||
.expect_err("missing local owner identity must fail closed");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ControlPlaneAuthError::MissingLocalOwnerIdentity {
|
||||
local_status: OwnershipStatus::Unsigned,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_plane_auth_rejects_unsupported_trust_policy() {
|
||||
let owner = OwnerKeypair::generate();
|
||||
let local_owner = verified_local_owner_summary(&owner);
|
||||
let remote_node_endpoint_id = [0xA1; 32];
|
||||
let remote_ownership = sign_node_ownership(
|
||||
&owner,
|
||||
&remote_node_endpoint_id,
|
||||
current_time_unix_ms() + 60_000,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = verify_control_plane_peer_ownership(
|
||||
&local_owner,
|
||||
Some(&proto_signed_node_ownership(&remote_ownership)),
|
||||
&remote_node_endpoint_id,
|
||||
&TrustStore::default(),
|
||||
TrustPolicy::Allowlist,
|
||||
current_time_unix_ms(),
|
||||
)
|
||||
.expect_err("allowlist trust policy must fail closed for owner-control auth");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ControlPlaneAuthError::UnsupportedTrustPolicy {
|
||||
policy: TrustPolicy::Allowlist,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_plane_auth_rejects_target_node_mismatch() {
|
||||
let err = verify_control_plane_target_node(&[0xCD; 32], &[0xAB; 32])
|
||||
.expect_err("wrong target node id must fail closed");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ControlPlaneAuthError::TargetNodeMismatch { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +1,8 @@
|
|||
mod keychain;
|
||||
mod keystore;
|
||||
mod ownership;
|
||||
mod control_plane;
|
||||
pub(crate) mod release_attestation;
|
||||
|
||||
pub use self::keychain::{
|
||||
DEFAULT_OWNER_ACCOUNT, KEYCHAIN_SERVICE, OwnerKeychainLoadError,
|
||||
delete_secret as keychain_delete, get_secret as keychain_get,
|
||||
is_available as keychain_available, load_owner_keypair_from_keychain,
|
||||
owner_account_for_path as owner_keychain_account_for_path, save_keystore_with_keychain,
|
||||
set_secret as keychain_set,
|
||||
};
|
||||
pub(crate) use self::keystore::write_keystore_bytes_atomically;
|
||||
pub use self::keystore::{
|
||||
KeystoreInfo, default_keystore_path, keystore_exists, keystore_metadata, load_keystore,
|
||||
save_keystore,
|
||||
};
|
||||
pub use self::ownership::{
|
||||
ControlPlaneAuthError, DEFAULT_NODE_CERT_LIFETIME_SECS, DEFAULT_NODE_CERT_RENEW_WINDOW_SECS,
|
||||
NodeOwnershipClaim, OwnershipStatus, OwnershipSummary, SignedNodeOwnership, TrustPolicy,
|
||||
TrustStore, certificate_needs_renewal, default_node_ownership_path, default_trust_store_path,
|
||||
load_node_ownership, load_trust_store, save_node_ownership, save_trust_store,
|
||||
sign_node_ownership, verify_control_plane_peer_ownership, verify_control_plane_target_node,
|
||||
verify_node_ownership,
|
||||
pub use self::control_plane::{
|
||||
ControlPlaneAuthError, verify_control_plane_peer_ownership, verify_control_plane_target_node,
|
||||
};
|
||||
pub use self::release_attestation::{
|
||||
EmbeddedReleaseAttestation, LoadedEmbeddedReleaseAttestation, ReleaseAttestationClaims,
|
||||
|
|
@ -31,7 +12,17 @@ pub use self::release_attestation::{
|
|||
load_release_signer_trust_store, parse_release_signer_public_key, release_signer_key_id,
|
||||
save_release_signer_trust_store, verify_release_attestation,
|
||||
};
|
||||
pub(crate) use mesh_llm_identity::keystore::write_keystore_bytes_atomically;
|
||||
pub use mesh_llm_identity::{
|
||||
CryptoError, OpenedMessage, OwnerKeypair, SignedEncryptedEnvelope, open_message,
|
||||
owner_id_from_verifying_key, seal_message,
|
||||
CryptoError, DEFAULT_NODE_CERT_LIFETIME_SECS, DEFAULT_NODE_CERT_RENEW_WINDOW_SECS,
|
||||
DEFAULT_OWNER_ACCOUNT, KEYCHAIN_SERVICE, KeystoreInfo, NodeOwnershipClaim, OpenedMessage,
|
||||
OwnerKeychainLoadError, OwnerKeypair, OwnershipStatus, OwnershipSummary,
|
||||
SignedEncryptedEnvelope, SignedNodeOwnership, TrustPolicy, TrustStore,
|
||||
certificate_needs_renewal, default_keystore_path, default_node_ownership_path,
|
||||
default_trust_store_path, keychain_available, keychain_delete, keychain_get, keychain_set,
|
||||
keystore_exists, keystore_metadata, load_keystore, load_node_ownership,
|
||||
load_owner_keypair_from_keychain, load_trust_store, open_message, owner_id_from_verifying_key,
|
||||
owner_keychain_account_for_path, save_keystore, save_keystore_with_keychain,
|
||||
save_node_ownership, save_trust_store, seal_message, sign_node_ownership,
|
||||
verify_node_ownership,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ use mesh_llm_system::embedded_release_footer::{
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::CryptoError;
|
||||
use super::keystore::write_keystore_bytes_atomically;
|
||||
use super::{CryptoError, write_keystore_bytes_atomically};
|
||||
|
||||
pub const RELEASE_BUILD_ATTESTATION_VERSION: u32 = 1;
|
||||
pub const RELEASE_SIGNER_TRUST_STORE_VERSION: u32 = 1;
|
||||
|
|
|
|||
47
crates/mesh-llm-host-runtime/src/discovery.rs
Normal file
47
crates/mesh-llm-host-runtime/src/discovery.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum MeshDiscoveryMode {
|
||||
#[default]
|
||||
Nostr,
|
||||
Mdns,
|
||||
}
|
||||
|
||||
impl MeshDiscoveryMode {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Nostr => "nostr",
|
||||
Self::Mdns => "mdns",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn source(self) -> &'static str {
|
||||
match self {
|
||||
Self::Nostr => "nostr-relay",
|
||||
Self::Mdns => "mdns-sd",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn scope(self) -> DiscoveryScope {
|
||||
match self {
|
||||
Self::Nostr => DiscoveryScope::Public,
|
||||
Self::Mdns => DiscoveryScope::Lan,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DiscoveryScope {
|
||||
Public,
|
||||
Lan,
|
||||
}
|
||||
|
||||
impl DiscoveryScope {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Public => "public",
|
||||
Self::Lan => "lan",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,146 +8,21 @@ fn passive_path_tui_still_starts_immediately() {
|
|||
runtime::assert_passive_path_immediate_spawn_behavior();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_serving_subcommands_retain_plain_output() {
|
||||
runtime::assert_non_serving_dispatch_short_circuit_behavior().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_lifecycle_transitions_pending_partial_ready_failed() {
|
||||
cli::output::assert_startup_lifecycle_transitions_pending_partial_ready_failed();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_lifecycle_keeps_runtime_ready_as_final_edge() {
|
||||
cli::output::assert_startup_lifecycle_keeps_runtime_ready_as_final_edge();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_failures_surface_in_tui_events_and_status() {
|
||||
cli::output::assert_startup_failures_surface_in_tui_events_and_status();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_failure_summary_sanitizes_multiline_detail() {
|
||||
cli::output::assert_startup_failure_summary_sanitizes_multiline_detail();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rpc_and_llama_startup_failures_mark_components_failed() {
|
||||
cli::output::assert_rpc_and_llama_startup_failures_mark_components_failed();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_and_join_failures_mark_startup_mesh_component_failed() {
|
||||
cli::output::assert_discovery_and_join_failures_mark_startup_mesh_component_failed();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_ready_peer_churn_does_not_reopen_startup_failure() {
|
||||
cli::output::assert_post_ready_peer_churn_does_not_reopen_startup_failure();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_handler_spawns_once_across_startup_callbacks() {
|
||||
runtime::assert_interactive_handler_spawns_once_across_startup_callbacks();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_history_is_visible_after_late_tui_attach() {
|
||||
cli::output::assert_startup_history_is_visible_after_late_tui_attach();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_history_keeps_order_when_tui_attaches_late() {
|
||||
cli::output::assert_startup_history_keeps_order_when_tui_attaches_late();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_rows_remain_starting_until_ready_events() {
|
||||
cli::output::assert_endpoint_rows_remain_starting_until_ready_events();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_launch_plan_renders_not_ready_rows_before_actions() {
|
||||
cli::output::assert_startup_launch_plan_renders_not_ready_rows_before_actions();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_model_progress_renders_dashboard_without_loading_screen() {
|
||||
cli::output::assert_tui_model_progress_renders_dashboard_without_loading_screen();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_startup_progress_continues_in_dashboard_after_model_download_ready() {
|
||||
cli::output::assert_tui_startup_progress_continues_in_dashboard_after_model_download_ready();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_progress_after_launch_plan_shows_dashboard_not_loader() {
|
||||
cli::output::assert_startup_progress_after_launch_plan_shows_dashboard_not_loader();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_rows_transition_from_not_ready_to_ready_events() {
|
||||
cli::output::assert_planned_rows_transition_from_not_ready_to_ready_events();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_plan_rows_survive_empty_startup_snapshot() {
|
||||
cli::output::assert_launch_plan_rows_survive_empty_startup_snapshot();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_plan_preserves_distinct_port_zero_endpoint_rows() {
|
||||
cli::output::assert_launch_plan_preserves_distinct_port_zero_endpoint_rows();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_upsert_preserves_distinct_port_zero_endpoint_rows() {
|
||||
cli::output::assert_snapshot_upsert_preserves_distinct_port_zero_endpoint_rows();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_port_zero_process_rows_bind_to_concrete_startup_events() {
|
||||
cli::output::assert_planned_port_zero_process_rows_bind_to_concrete_startup_events();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_launch_plan_describes_planned_runtime_before_process_start() {
|
||||
runtime::assert_startup_launch_plan_describes_planned_runtime_before_process_start();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_mode_surfaces_startup_failures_without_tui() {
|
||||
cli::output::assert_fallback_mode_surfaces_startup_failures_without_tui();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_during_startup_cancels_without_late_ready_render() {
|
||||
runtime::assert_quitting_during_startup_cancels_without_late_ready_render();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_preterminal_render_uses_plain_event_output() {
|
||||
cli::output::assert_interactive_preterminal_render_uses_plain_event_output();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_post_terminal_exit_resumes_plain_event_output() {
|
||||
cli::output::assert_interactive_post_terminal_exit_resumes_plain_event_output();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_model_card_separates_name_from_metadata_columns() {
|
||||
cli::output::assert_tui_model_card_separates_name_from_metadata_columns();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_requirements_docs_examples_parse() {
|
||||
cli::assert_mesh_requirements_docs_examples_parse();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_requirements_policy_canonical_hash_is_stable() {
|
||||
mesh::requirements::tests::assert_mesh_requirements_policy_canonical_hash_is_stable();
|
||||
|
|
|
|||
|
|
@ -13,17 +13,17 @@ use super::materialization::{
|
|||
const RUNTIME_SMOKE_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SkippyCertificationRequest {
|
||||
pub(crate) model_ref: String,
|
||||
pub(crate) package_only: bool,
|
||||
pub(crate) api_base: Option<String>,
|
||||
pub(crate) prompt: String,
|
||||
pub(crate) max_tokens: u32,
|
||||
pub struct SkippyCertificationRequest {
|
||||
pub model_ref: String,
|
||||
pub package_only: bool,
|
||||
pub api_base: Option<String>,
|
||||
pub prompt: String,
|
||||
pub max_tokens: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum CertificationGateStatus {
|
||||
pub enum CertificationGateStatus {
|
||||
Passed,
|
||||
Failed,
|
||||
Incomplete,
|
||||
|
|
@ -31,43 +31,43 @@ pub(crate) enum CertificationGateStatus {
|
|||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct SkippyCertificationReport {
|
||||
pub(crate) schema_version: u32,
|
||||
pub(crate) status: CertificationGateStatus,
|
||||
pub(crate) input: String,
|
||||
pub(crate) resolved_package_ref: String,
|
||||
pub(crate) local_package_dir: String,
|
||||
pub(crate) model_id: String,
|
||||
pub(crate) manifest_sha256: String,
|
||||
pub(crate) source_model_path: String,
|
||||
pub(crate) source_model_sha256: String,
|
||||
pub(crate) source_model_bytes: Option<u64>,
|
||||
pub(crate) layer_count: u32,
|
||||
pub(crate) package_gate: CertificationGate,
|
||||
pub(crate) materialized_stages: Vec<CertifiedStage>,
|
||||
pub(crate) runtime_gates: Vec<CertificationGate>,
|
||||
pub struct SkippyCertificationReport {
|
||||
pub schema_version: u32,
|
||||
pub status: CertificationGateStatus,
|
||||
pub input: String,
|
||||
pub resolved_package_ref: String,
|
||||
pub local_package_dir: String,
|
||||
pub model_id: String,
|
||||
pub manifest_sha256: String,
|
||||
pub source_model_path: String,
|
||||
pub source_model_sha256: String,
|
||||
pub source_model_bytes: Option<u64>,
|
||||
pub layer_count: u32,
|
||||
pub package_gate: CertificationGate,
|
||||
pub materialized_stages: Vec<CertifiedStage>,
|
||||
pub runtime_gates: Vec<CertificationGate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct CertificationGate {
|
||||
pub(crate) name: String,
|
||||
pub(crate) status: CertificationGateStatus,
|
||||
pub struct CertificationGate {
|
||||
pub name: String,
|
||||
pub status: CertificationGateStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) details: Option<String>,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct CertifiedStage {
|
||||
pub(crate) stage_id: String,
|
||||
pub(crate) layer_start: u32,
|
||||
pub(crate) layer_end: u32,
|
||||
pub(crate) include_embeddings: bool,
|
||||
pub(crate) include_output: bool,
|
||||
pub(crate) selected_part_count: usize,
|
||||
pub(crate) verified_artifacts: usize,
|
||||
pub(crate) cached_artifacts: usize,
|
||||
pub(crate) materialized_path: String,
|
||||
pub(crate) materialized_bytes: u64,
|
||||
pub struct CertifiedStage {
|
||||
pub stage_id: String,
|
||||
pub layer_start: u32,
|
||||
pub layer_end: u32,
|
||||
pub include_embeddings: bool,
|
||||
pub include_output: bool,
|
||||
pub selected_part_count: usize,
|
||||
pub verified_artifacts: usize,
|
||||
pub cached_artifacts: usize,
|
||||
pub materialized_path: String,
|
||||
pub materialized_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
|
|
@ -78,7 +78,7 @@ struct CertificationStageRange {
|
|||
include_output: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn certify_layer_package(
|
||||
pub async fn certify_layer_package(
|
||||
request: SkippyCertificationRequest,
|
||||
) -> Result<SkippyCertificationReport> {
|
||||
let resolved_package_ref = resolve_certification_package_ref(&request.model_ref)?;
|
||||
|
|
@ -119,7 +119,7 @@ pub(crate) async fn certify_layer_package(
|
|||
})
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_certification_package_ref(input: &str) -> Result<String> {
|
||||
pub fn resolve_certification_package_ref(input: &str) -> Result<String> {
|
||||
if let Ok(package_ref) = StagePackageRef::parse(input) {
|
||||
if let Some(package_ref) = package_ref.as_package_ref() {
|
||||
return Ok(package_ref);
|
||||
|
|
|
|||
|
|
@ -15,15 +15,15 @@ use skippy_runtime::package::{
|
|||
self, LayerPackageInfo, PackageIntegrityOptions, PackageStageRequest,
|
||||
};
|
||||
|
||||
use crate::cli::output::{ModelProgressStatus, OutputEvent, emit_event, interactive_tui_active};
|
||||
use crate::cli::terminal_progress::{SpinnerHandle, start_spinner};
|
||||
use mesh_llm_events::terminal_progress::{SpinnerHandle, start_spinner};
|
||||
use mesh_llm_events::{ModelProgressStatus, OutputEvent, emit_event, interactive_tui_active};
|
||||
|
||||
use super::StageLoadRequest;
|
||||
|
||||
mod cache_resolution;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum StagePackageRef {
|
||||
pub enum StagePackageRef {
|
||||
LocalPackage(PathBuf),
|
||||
HuggingFacePackage {
|
||||
repo: String,
|
||||
|
|
@ -33,7 +33,7 @@ pub(crate) enum StagePackageRef {
|
|||
}
|
||||
|
||||
impl StagePackageRef {
|
||||
pub(crate) fn parse(value: &str) -> Result<Self> {
|
||||
pub fn parse(value: &str) -> Result<Self> {
|
||||
if let Some(rest) = value.strip_prefix("hf://") {
|
||||
let (repo, revision) = if let Some((repo, revision)) = rest.split_once('@') {
|
||||
(repo, Some(revision.to_string()))
|
||||
|
|
@ -62,14 +62,14 @@ impl StagePackageRef {
|
|||
bail!("not a skippy package ref: {value}");
|
||||
}
|
||||
|
||||
pub(crate) fn is_distributable_package(&self) -> bool {
|
||||
pub fn is_distributable_package(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::LocalPackage(_) | Self::HuggingFacePackage { .. }
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn as_package_ref(&self) -> Option<String> {
|
||||
pub fn as_package_ref(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::LocalPackage(path) => Some(path.to_string_lossy().to_string()),
|
||||
Self::HuggingFacePackage { repo, revision } => Some(match revision {
|
||||
|
|
@ -82,47 +82,47 @@ impl StagePackageRef {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct StagePackageInfo {
|
||||
pub(crate) package_ref: String,
|
||||
pub(crate) package_dir: PathBuf,
|
||||
pub(crate) manifest_sha256: String,
|
||||
pub(crate) model_id: String,
|
||||
pub(crate) source_model_path: String,
|
||||
pub(crate) source_model_sha256: String,
|
||||
pub(crate) source_model_bytes: Option<u64>,
|
||||
pub(crate) layer_count: u32,
|
||||
pub(crate) activation_width: u32,
|
||||
pub(crate) projector_path: Option<String>,
|
||||
pub(crate) layers: Vec<StagePackageLayerInfo>,
|
||||
pub struct StagePackageInfo {
|
||||
pub package_ref: String,
|
||||
pub package_dir: PathBuf,
|
||||
pub manifest_sha256: String,
|
||||
pub model_id: String,
|
||||
pub source_model_path: String,
|
||||
pub source_model_sha256: String,
|
||||
pub source_model_bytes: Option<u64>,
|
||||
pub layer_count: u32,
|
||||
pub activation_width: u32,
|
||||
pub projector_path: Option<String>,
|
||||
pub layers: Vec<StagePackageLayerInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct StagePackageLayerInfo {
|
||||
pub(crate) layer_index: u32,
|
||||
pub(crate) tensor_count: usize,
|
||||
pub(crate) tensor_bytes: u64,
|
||||
pub(crate) artifact_bytes: u64,
|
||||
pub struct StagePackageLayerInfo {
|
||||
pub layer_index: u32,
|
||||
pub tensor_count: usize,
|
||||
pub tensor_bytes: u64,
|
||||
pub artifact_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct MaterializedStageArtifact {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) manifest_sha256: String,
|
||||
pub(crate) source_model_path: String,
|
||||
pub(crate) source_model_sha256: String,
|
||||
pub(crate) source_model_bytes: Option<u64>,
|
||||
pub struct MaterializedStageArtifact {
|
||||
pub path: PathBuf,
|
||||
pub manifest_sha256: String,
|
||||
pub source_model_path: String,
|
||||
pub source_model_sha256: String,
|
||||
pub source_model_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct ResolvedStagePackage {
|
||||
pub(crate) local_ref: String,
|
||||
pub(crate) source_model_path: String,
|
||||
pub(crate) source_model_sha256: String,
|
||||
pub(crate) source_model_bytes: Option<u64>,
|
||||
pub struct ResolvedStagePackage {
|
||||
pub local_ref: String,
|
||||
pub source_model_path: String,
|
||||
pub source_model_sha256: String,
|
||||
pub source_model_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct MaterializedStagePin {
|
||||
pub struct MaterializedStagePin {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
|
|
@ -141,14 +141,14 @@ struct PinFile {
|
|||
stage_id: String,
|
||||
}
|
||||
|
||||
pub(crate) fn configure_materialized_stage_cache() {
|
||||
pub fn configure_materialized_stage_cache() {
|
||||
if std::env::var_os("SKIPPY_MATERIALIZED_DIR").is_none() {
|
||||
// TODO: Audit that the environment access only happens in single-threaded code.
|
||||
unsafe { std::env::set_var("SKIPPY_MATERIALIZED_DIR", materialized_stage_cache_dir()) };
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn materialized_stage_cache_dir() -> PathBuf {
|
||||
pub fn materialized_stage_cache_dir() -> PathBuf {
|
||||
crate::models::mesh_llm_cache_dir().join("skippy-stages")
|
||||
}
|
||||
|
||||
|
|
@ -556,7 +556,7 @@ fn draw_layer_package_file_progress(
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_layer_package_ref(value: &str) -> bool {
|
||||
pub fn is_layer_package_ref(value: &str) -> bool {
|
||||
StagePackageRef::parse(value).is_ok_and(|package_ref| package_ref.is_distributable_package())
|
||||
}
|
||||
|
||||
|
|
@ -785,7 +785,7 @@ fn download_layer_package_file(
|
|||
Ok(path)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_hf_package_to_local(
|
||||
pub fn resolve_hf_package_to_local(
|
||||
package_ref: &str,
|
||||
layer_start: u32,
|
||||
layer_end: u32,
|
||||
|
|
@ -1068,7 +1068,7 @@ fn safe_manifest_file_path(path: &str) -> Result<PathBuf> {
|
|||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_package_manifest_sha(package_ref: &str, expected_sha256: &str) -> Result<()> {
|
||||
pub fn ensure_package_manifest_sha(package_ref: &str, expected_sha256: &str) -> Result<()> {
|
||||
if expected_sha256.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -1086,7 +1086,7 @@ pub(crate) fn ensure_package_manifest_sha(package_ref: &str, expected_sha256: &s
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn inspect_stage_package(package_ref: &str) -> Result<StagePackageInfo> {
|
||||
pub fn inspect_stage_package(package_ref: &str) -> Result<StagePackageInfo> {
|
||||
// Resolve hf:// to local for inspection, downloading the manifest and any
|
||||
// shared package metadata that resolver path needs.
|
||||
let local_ref = resolve_hf_package_to_local(package_ref, 0, 0, false, false)?;
|
||||
|
|
@ -1098,9 +1098,7 @@ pub(crate) fn inspect_stage_package(package_ref: &str) -> Result<StagePackageInf
|
|||
/// Resolve an `hf://` package ref in a stage load request to a local directory.
|
||||
/// Returns the resolved local path if the package ref needed resolution, or `None`
|
||||
/// if it was already local / not a layer package.
|
||||
pub(crate) fn resolve_stage_load_package(
|
||||
load: &StageLoadRequest,
|
||||
) -> Result<Option<ResolvedStagePackage>> {
|
||||
pub fn resolve_stage_load_package(load: &StageLoadRequest) -> Result<Option<ResolvedStagePackage>> {
|
||||
if load.load_mode != LoadMode::LayerPackage {
|
||||
return Ok(None);
|
||||
}
|
||||
|
|
@ -1127,7 +1125,7 @@ pub(crate) fn resolve_stage_load_package(
|
|||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn materialize_stage_config(
|
||||
pub fn materialize_stage_config(
|
||||
config: &StageConfig,
|
||||
) -> Result<Option<(MaterializedStageArtifact, MaterializedStagePin)>> {
|
||||
if config.load_mode != LoadMode::LayerPackage {
|
||||
|
|
@ -1186,7 +1184,7 @@ pub(crate) fn materialize_stage_config(
|
|||
Ok(Some((artifact, pin)))
|
||||
}
|
||||
|
||||
pub(crate) fn prune_unpinned_materialized_stages() -> Result<usize> {
|
||||
pub fn prune_unpinned_materialized_stages() -> Result<usize> {
|
||||
let root = materialized_stage_cache_dir();
|
||||
if !root.is_dir() {
|
||||
return Ok(0);
|
||||
|
|
@ -1229,7 +1227,7 @@ pub(crate) fn prune_unpinned_materialized_stages() -> Result<usize> {
|
|||
Ok(removed)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_materialized_stages_for_sources(sources: &[PathBuf]) -> Result<usize> {
|
||||
pub fn remove_materialized_stages_for_sources(sources: &[PathBuf]) -> Result<usize> {
|
||||
let candidates = materialized_stage_removal_candidates(sources)?;
|
||||
let mut removed = 0usize;
|
||||
for candidate in candidates {
|
||||
|
|
@ -1241,7 +1239,7 @@ pub(crate) fn remove_materialized_stages_for_sources(sources: &[PathBuf]) -> Res
|
|||
Ok(removed)
|
||||
}
|
||||
|
||||
pub(crate) fn materialized_stages_for_sources(sources: &[PathBuf]) -> Result<Vec<PathBuf>> {
|
||||
pub fn materialized_stages_for_sources(sources: &[PathBuf]) -> Result<Vec<PathBuf>> {
|
||||
Ok(materialized_stage_removal_candidates(sources)?
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.artifact_path.exists())
|
||||
|
|
|
|||
|
|
@ -36,19 +36,19 @@ use skippy_server::{
|
|||
embedded_openai_backend, telemetry::Telemetry, telemetry::TelemetryLevel,
|
||||
};
|
||||
|
||||
pub(crate) use certification::{
|
||||
pub use certification::{
|
||||
CertificationGateStatus, SkippyCertificationRequest, certify_layer_package,
|
||||
};
|
||||
pub(crate) use family_policy::{family_policy_for_model_path, family_policy_for_stage_config};
|
||||
pub(crate) use hooks::MeshAutoHookPolicy;
|
||||
pub(crate) use kv_cache::KvCachePolicy;
|
||||
pub(crate) use materialization::{
|
||||
pub use materialization::{
|
||||
configure_materialized_stage_cache, is_layer_package_ref, materialize_stage_config,
|
||||
materialized_stage_cache_dir, materialized_stages_for_sources,
|
||||
prune_unpinned_materialized_stages, remove_materialized_stages_for_sources,
|
||||
resolve_hf_package_to_local,
|
||||
};
|
||||
pub(crate) use package::{
|
||||
pub use package::{
|
||||
SkippyPackageIdentity, identity_from_layer_package, synthetic_direct_gguf_package,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
|
|
|
|||
|
|
@ -9,23 +9,23 @@ use serde::Serialize;
|
|||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct SkippyPackageIdentity {
|
||||
pub(crate) package_ref: String,
|
||||
pub(crate) manifest_sha256: String,
|
||||
pub(crate) source_model_path: PathBuf,
|
||||
pub(crate) source_model_sha256: String,
|
||||
pub(crate) source_model_bytes: u64,
|
||||
pub(crate) source_files: Vec<SkippyPackageSourceFile>,
|
||||
pub(crate) layer_count: u32,
|
||||
pub(crate) activation_width: u32,
|
||||
pub(crate) tensor_count: u64,
|
||||
pub struct SkippyPackageIdentity {
|
||||
pub package_ref: String,
|
||||
pub manifest_sha256: String,
|
||||
pub source_model_path: PathBuf,
|
||||
pub source_model_sha256: String,
|
||||
pub source_model_bytes: u64,
|
||||
pub source_files: Vec<SkippyPackageSourceFile>,
|
||||
pub layer_count: u32,
|
||||
pub activation_width: u32,
|
||||
pub tensor_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub(crate) struct SkippyPackageSourceFile {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) bytes: u64,
|
||||
pub(crate) sha256: String,
|
||||
pub struct SkippyPackageSourceFile {
|
||||
pub path: PathBuf,
|
||||
pub bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -52,7 +52,7 @@ struct SyntheticGgufManifestFile {
|
|||
sha256: String,
|
||||
}
|
||||
|
||||
pub(crate) fn synthetic_direct_gguf_package(
|
||||
pub fn synthetic_direct_gguf_package(
|
||||
model_id: &str,
|
||||
model_path: &Path,
|
||||
) -> Result<SkippyPackageIdentity> {
|
||||
|
|
@ -300,7 +300,7 @@ fn hex_lower(bytes: &[u8]) -> String {
|
|||
/// the manifest and shared metadata that the resolver requires, but not layer
|
||||
/// files. Layer artifacts are fetched later by the node that materializes or
|
||||
/// loads its assigned stage.
|
||||
pub(crate) fn identity_from_layer_package(package_ref: &str) -> Result<SkippyPackageIdentity> {
|
||||
pub fn identity_from_layer_package(package_ref: &str) -> Result<SkippyPackageIdentity> {
|
||||
// Resolve hf:// to a local package dir for lightweight package inspection.
|
||||
let local_ref =
|
||||
super::materialization::resolve_hf_package_to_local(package_ref, 0, 0, false, false)?;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
mod api;
|
||||
mod capture;
|
||||
mod cli;
|
||||
pub mod command_support;
|
||||
pub mod crypto;
|
||||
mod inference;
|
||||
pub mod discovery;
|
||||
pub mod inference;
|
||||
mod mesh;
|
||||
mod models;
|
||||
pub mod models;
|
||||
mod network;
|
||||
mod plugin;
|
||||
pub mod plugin;
|
||||
mod plugins;
|
||||
mod protocol;
|
||||
mod runtime;
|
||||
|
|
@ -37,23 +38,45 @@ pub use mesh::requirements::{
|
|||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub use runtime::{
|
||||
MeshGuardrailMode, RuntimeOptions, RuntimeSurface, console_session_mode_for_runtime_surface,
|
||||
};
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
initialize_host_runtime()?;
|
||||
runtime::run().await
|
||||
}
|
||||
|
||||
pub async fn run_main() -> i32 {
|
||||
match run().await {
|
||||
Ok(()) => 0,
|
||||
Err(err) => {
|
||||
let _ = cli::output::emit_fatal_error(&err);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
1
|
||||
}
|
||||
pub async fn run_runtime(
|
||||
options: RuntimeOptions,
|
||||
explicit_surface: Option<RuntimeSurface>,
|
||||
legacy_warning: Option<String>,
|
||||
) -> Result<()> {
|
||||
initialize_host_runtime()?;
|
||||
run_runtime_initialized(options, explicit_surface, legacy_warning).await
|
||||
}
|
||||
|
||||
pub async fn run_runtime_initialized(
|
||||
options: RuntimeOptions,
|
||||
explicit_surface: Option<RuntimeSurface>,
|
||||
legacy_warning: Option<String>,
|
||||
) -> Result<()> {
|
||||
runtime::run_cli(options, explicit_surface, legacy_warning).await
|
||||
}
|
||||
|
||||
pub fn initialize_host_runtime() -> Result<()> {
|
||||
#[cfg(feature = "dynamic-native-runtime")]
|
||||
if let Some(runtime) = system::native_runtime::try_load_installed_native_runtime()? {
|
||||
tracing::info!(
|
||||
native_runtime_id = %runtime.native_runtime_id,
|
||||
libraries = ?runtime.libraries,
|
||||
"Loaded MeshLLM native runtime"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use anyhow::{Context, Result};
|
|||
use base64::Engine;
|
||||
use iroh::endpoint::Connection;
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, TransportAddr};
|
||||
use mesh_llm_events::OutputEvent;
|
||||
use prost::Message;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
|
@ -52,14 +53,14 @@ const SIGNED_BOOTSTRAP_TOKEN_LIFETIME_MS: u64 = 24 * 60 * 60 * 1000;
|
|||
const RECENT_MESH_REJECTION_LIMIT: usize = 16;
|
||||
|
||||
fn emit_mesh_info(message: String) {
|
||||
let _ = crate::cli::output::emit_event(crate::cli::output::OutputEvent::Info {
|
||||
let _ = mesh_llm_events::emit_event(OutputEvent::Info {
|
||||
message,
|
||||
context: None,
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_mesh_warning(message: String) {
|
||||
let _ = crate::cli::output::emit_event(crate::cli::output::OutputEvent::Warning {
|
||||
let _ = mesh_llm_events::emit_event(OutputEvent::Warning {
|
||||
message,
|
||||
context: None,
|
||||
});
|
||||
|
|
@ -399,16 +400,17 @@ pub struct RelayConfig<'a> {
|
|||
pub enum RelayPolicy {
|
||||
#[default]
|
||||
DefaultPublic,
|
||||
ExplicitlyDisabled,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl RelayPolicy {
|
||||
fn uses_relay(self) -> bool {
|
||||
pub(crate) fn uses_relay(self) -> bool {
|
||||
matches!(self, Self::DefaultPublic)
|
||||
}
|
||||
|
||||
fn uses_raw_stun(self) -> bool {
|
||||
matches!(self, Self::DefaultPublic)
|
||||
matches!(self, Self::DefaultPublic | Self::ExplicitlyDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -557,7 +559,7 @@ fn filter_endpoint_addr_for_bind_ip(
|
|||
|
||||
fn effective_relay_urls(policy: RelayPolicy, relay_urls: &[String]) -> Vec<String> {
|
||||
match policy {
|
||||
RelayPolicy::Disabled => Vec::new(),
|
||||
RelayPolicy::Disabled | RelayPolicy::ExplicitlyDisabled => Vec::new(),
|
||||
RelayPolicy::DefaultPublic if relay_urls.is_empty() => vec![
|
||||
"https://usw1-2.relay.michaelneale.mesh-llm.iroh.link./".into(),
|
||||
"https://aps1-1.relay.michaelneale.mesh-llm.iroh.link./".into(),
|
||||
|
|
@ -588,12 +590,15 @@ mod relay_policy_tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_policy_uses_no_relays_or_raw_stun() {
|
||||
fn disabled_policy_uses_no_relays_but_explicit_disable_keeps_raw_stun() {
|
||||
let custom = vec!["https://relay.example/".to_string()];
|
||||
|
||||
assert!(effective_relay_urls(RelayPolicy::Disabled, &custom).is_empty());
|
||||
assert!(effective_relay_urls(RelayPolicy::ExplicitlyDisabled, &custom).is_empty());
|
||||
assert!(!RelayPolicy::Disabled.uses_relay());
|
||||
assert!(!RelayPolicy::ExplicitlyDisabled.uses_relay());
|
||||
assert!(!RelayPolicy::Disabled.uses_raw_stun());
|
||||
assert!(RelayPolicy::ExplicitlyDisabled.uses_raw_stun());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2032,7 +2037,12 @@ fn relay_mode_for_startup(relay: RelayConfig<'_>) -> iroh::endpoint::RelayMode {
|
|||
tracing::info!("Relay: {:?}", urls);
|
||||
iroh::endpoint::RelayMode::Custom(relay_map_from_urls(&urls, relay.auths))
|
||||
} else {
|
||||
tracing::info!("Relay: disabled by LAN-only discovery mode");
|
||||
let reason = match relay.policy {
|
||||
RelayPolicy::ExplicitlyDisabled => "disabled by embedded config",
|
||||
RelayPolicy::Disabled => "disabled by LAN-only discovery mode",
|
||||
RelayPolicy::DefaultPublic => unreachable!("default public uses relays"),
|
||||
};
|
||||
tracing::info!("Relay: {reason}");
|
||||
iroh::endpoint::RelayMode::Disabled
|
||||
}
|
||||
}
|
||||
|
|
@ -2449,11 +2459,15 @@ pub(crate) fn is_peer_admitted(peers: &HashMap<EndpointId, PeerInfo>, id: &Endpo
|
|||
/// - `STREAM_GOSSIP (0x01)`: the admission handshake itself.
|
||||
/// - `STREAM_ROUTE_REQUEST (0x05)`: passive/client request-only path — caller
|
||||
/// is NEVER promoted to `state.peers`.
|
||||
/// - `STREAM_TUNNEL_HTTP (0x04)`: passive SDK inference path for callers that
|
||||
/// have an invite token but should not need a local `/v1` HTTP listener.
|
||||
///
|
||||
/// Every other stream — including tunnel (0x02 / 0x04) — requires the
|
||||
/// remote to have completed gossip first.
|
||||
/// Every other stream — including raw tunnel (0x02) — requires the remote to
|
||||
/// have completed gossip first.
|
||||
pub(crate) fn stream_allowed_before_admission(stream_type: u8) -> bool {
|
||||
stream_type == STREAM_GOSSIP || stream_type == STREAM_ROUTE_REQUEST
|
||||
stream_type == STREAM_GOSSIP
|
||||
|| stream_type == STREAM_ROUTE_REQUEST
|
||||
|| stream_type == STREAM_TUNNEL_HTTP
|
||||
}
|
||||
|
||||
pub(crate) fn ingest_tunnel_map(
|
||||
|
|
@ -10242,19 +10256,8 @@ pub fn clear_public_identity() {
|
|||
/// Load secret key from ~/.mesh-llm/key, or create a new one and save it.
|
||||
async fn load_or_create_key() -> Result<SecretKey> {
|
||||
let key_path = default_node_key_path()?;
|
||||
let dir = key_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid node key path {}", key_path.display()))?;
|
||||
ensure_private_node_key_dir(dir)?;
|
||||
|
||||
if key_path.exists() {
|
||||
ensure_private_node_key_file(&key_path)?;
|
||||
let hex = tokio::fs::read_to_string(&key_path).await?;
|
||||
let bytes = hex::decode(hex.trim())?;
|
||||
if bytes.len() != 32 {
|
||||
anyhow::bail!("Invalid key length in {}", key_path.display());
|
||||
}
|
||||
let key = SecretKey::from_bytes(&bytes.try_into().unwrap());
|
||||
let key = load_node_key_from_path(&key_path)?;
|
||||
tracing::info!("Loaded key from {}", key_path.display());
|
||||
return Ok(key);
|
||||
}
|
||||
|
|
@ -10266,75 +10269,17 @@ async fn load_or_create_key() -> Result<SecretKey> {
|
|||
}
|
||||
|
||||
pub fn default_node_key_path() -> Result<std::path::PathBuf> {
|
||||
let home =
|
||||
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
|
||||
Ok(home.join(".mesh-llm").join("key"))
|
||||
Ok(mesh_llm_identity::default_node_key_path()?)
|
||||
}
|
||||
|
||||
pub fn load_node_key_from_path(path: &std::path::Path) -> Result<SecretKey> {
|
||||
let hex = std::fs::read_to_string(path)?;
|
||||
let bytes = hex::decode(hex.trim())?;
|
||||
if bytes.len() != 32 {
|
||||
anyhow::bail!("Invalid key length in {}", path.display());
|
||||
}
|
||||
Ok(SecretKey::from_bytes(&bytes.try_into().unwrap()))
|
||||
Ok(SecretKey::from_bytes(
|
||||
&mesh_llm_identity::load_node_key_bytes_from_path(path)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn save_node_key_to_path(path: &std::path::Path, key: &SecretKey) -> Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid node key path {}", path.display()))?;
|
||||
ensure_private_node_key_dir(parent)?;
|
||||
if path.exists() {
|
||||
ensure_private_node_key_file(path)?;
|
||||
}
|
||||
crate::crypto::write_keystore_bytes_atomically(path, hex::encode(key.to_bytes()).as_bytes())?;
|
||||
ensure_private_node_key_file(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ensure_private_node_key_dir(dir: &std::path::Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let metadata = std::fs::metadata(dir)?;
|
||||
let mut perms = metadata.permissions();
|
||||
if perms.mode() & 0o077 != 0 {
|
||||
perms.set_mode(0o700);
|
||||
std::fs::set_permissions(dir, perms)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn ensure_private_node_key_dir(dir: &std::path::Path) -> Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ensure_private_node_key_file(path: &std::path::Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let metadata = std::fs::symlink_metadata(path)?;
|
||||
if !metadata.file_type().is_file() {
|
||||
anyhow::bail!("Node key path is not a regular file");
|
||||
}
|
||||
let mut perms = metadata.permissions();
|
||||
if perms.mode() & 0o077 != 0 {
|
||||
perms.set_mode(0o600);
|
||||
std::fs::set_permissions(path, perms)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn ensure_private_node_key_file(path: &std::path::Path) -> Result<()> {
|
||||
let metadata = std::fs::symlink_metadata(path)?;
|
||||
if !metadata.file_type().is_file() {
|
||||
anyhow::bail!("Node key path is not a regular file");
|
||||
}
|
||||
mesh_llm_identity::save_node_key_bytes_to_path(path, &key.to_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3241,13 +3241,14 @@ fn incoming_peer_promoted_after_valid_gossip() {
|
|||
"peer must NOT be admitted before gossip"
|
||||
);
|
||||
|
||||
for &tunnel_stream in &[STREAM_TUNNEL, STREAM_TUNNEL_HTTP] {
|
||||
assert!(
|
||||
!stream_allowed_before_admission(tunnel_stream),
|
||||
"stream {:#04x} must be gated until after admission — unadmitted peers must not reach tunnel paths",
|
||||
tunnel_stream
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!stream_allowed_before_admission(STREAM_TUNNEL),
|
||||
"raw tunnel streams must be gated until after admission"
|
||||
);
|
||||
assert!(
|
||||
stream_allowed_before_admission(STREAM_TUNNEL_HTTP),
|
||||
"HTTP tunnel streams must be allowed for passive SDK clients"
|
||||
);
|
||||
|
||||
assert!(
|
||||
stream_allowed_before_admission(STREAM_GOSSIP),
|
||||
|
|
@ -3296,7 +3297,6 @@ fn incoming_peer_rejected_on_legacy_or_malformed_gossip() {
|
|||
|
||||
for stream_type in [
|
||||
STREAM_TUNNEL,
|
||||
STREAM_TUNNEL_HTTP,
|
||||
STREAM_TUNNEL_MAP,
|
||||
STREAM_PEER_DOWN,
|
||||
STREAM_PEER_LEAVING,
|
||||
|
|
@ -3319,6 +3319,10 @@ fn incoming_peer_rejected_on_legacy_or_malformed_gossip() {
|
|||
stream_allowed_before_admission(STREAM_ROUTE_REQUEST),
|
||||
"STREAM_ROUTE_REQUEST must bypass the gate (passive/client request-only path)"
|
||||
);
|
||||
assert!(
|
||||
stream_allowed_before_admission(STREAM_TUNNEL_HTTP),
|
||||
"STREAM_TUNNEL_HTTP must bypass the gate (passive/client inference path)"
|
||||
);
|
||||
|
||||
let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xcd; 32]).public());
|
||||
let peers: HashMap<EndpointId, PeerInfo> = HashMap::new();
|
||||
|
|
@ -3345,7 +3349,6 @@ fn passive_route_table_request_does_not_admit_peer() {
|
|||
|
||||
for &gated in &[
|
||||
STREAM_TUNNEL,
|
||||
STREAM_TUNNEL_HTTP,
|
||||
STREAM_TUNNEL_MAP,
|
||||
STREAM_PEER_DOWN,
|
||||
STREAM_PEER_LEAVING,
|
||||
|
|
@ -5186,18 +5189,16 @@ fn dead_peer_ttl_expires() {
|
|||
/// Verifies that non-scope tunnel streams (0x02 STREAM_TUNNEL and 0x04
|
||||
/// STREAM_TUNNEL_HTTP) are NOT subject to protobuf frame validation — they are
|
||||
/// raw byte pass-throughs and must not be accidentally broken by the cut-over.
|
||||
/// Also verifies they are correctly gated by admission policy.
|
||||
/// Also verifies their admission policy.
|
||||
#[test]
|
||||
fn non_scope_tunnel_streams_pass_through_without_proto_validation() {
|
||||
// 0x02 and 0x04 must NOT be allowed before admission (they are raw TCP tunnels,
|
||||
// quarantined until the peer is admitted via gossip).
|
||||
assert!(
|
||||
!stream_allowed_before_admission(STREAM_TUNNEL),
|
||||
"STREAM_TUNNEL (0x02) must be gated until after gossip admission"
|
||||
);
|
||||
assert!(
|
||||
!stream_allowed_before_admission(STREAM_TUNNEL_HTTP),
|
||||
"STREAM_TUNNEL_HTTP (0x04) must be gated until after gossip admission"
|
||||
stream_allowed_before_admission(STREAM_TUNNEL_HTTP),
|
||||
"STREAM_TUNNEL_HTTP (0x04) must be allowed for passive SDK inference"
|
||||
);
|
||||
|
||||
// After admission these streams are live. Verify that the stream type constants
|
||||
|
|
@ -5241,15 +5242,10 @@ fn non_scope_tunnel_streams_pass_through_without_proto_validation() {
|
|||
err
|
||||
);
|
||||
|
||||
// Verify that all admission-gated streams besides tunnels are also gated
|
||||
// (completeness check for non-scope stream policy)
|
||||
for stream in [STREAM_TUNNEL, STREAM_TUNNEL_HTTP] {
|
||||
assert!(
|
||||
!stream_allowed_before_admission(stream),
|
||||
"stream {:#04x} must require admission (raw tunnel security boundary)",
|
||||
stream
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!stream_allowed_before_admission(STREAM_TUNNEL),
|
||||
"STREAM_TUNNEL must require admission (raw tunnel security boundary)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Proves the behavioral contract introduced in the reconnect fix:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
//! Managed model acquisition helpers.
|
||||
|
||||
use super::track_managed_model_usage;
|
||||
use crate::cli::output::{ModelProgressStatus, OutputEvent, emit_event, interactive_tui_active};
|
||||
use crate::cli::terminal_progress::{SpinnerHandle, start_spinner};
|
||||
use anyhow::{Context, Result};
|
||||
use hf_hub::progress::{DownloadEvent, Progress, ProgressEvent, ProgressHandler};
|
||||
#[cfg(test)]
|
||||
use hf_hub::progress::{FileProgress, FileStatus};
|
||||
use mesh_llm_events::terminal_progress::{SpinnerHandle, start_spinner};
|
||||
use mesh_llm_events::{ModelProgressStatus, OutputEvent, emit_event, interactive_tui_active};
|
||||
#[cfg(test)]
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
|
|
|||
|
|
@ -1,326 +1,25 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use model_hf::store::delete::DeleteModelCatalog;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use hf_hub::cache::{CachedFileInfo, CachedRevisionInfo, HFCacheInfo};
|
||||
use hf_hub::{RepoType, RepoTypeModel};
|
||||
|
||||
use crate::models::local::{
|
||||
gguf_metadata_cache_path, huggingface_hub_cache_dir, huggingface_identity_for_path,
|
||||
mesh_llm_cache_dir, scan_hf_cache_info, split_gguf_base_name,
|
||||
};
|
||||
use crate::models::resolve::{
|
||||
DeleteModelRef, parse_delete_model_ref, resolve_huggingface_file_from_sibling_entries,
|
||||
};
|
||||
use crate::models::usage;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DeleteResult {
|
||||
pub deleted_paths: Vec<PathBuf>,
|
||||
pub reclaimed_bytes: u64,
|
||||
pub removed_metadata_files: usize,
|
||||
pub removed_usage_records: usize,
|
||||
pub removed_derived_cache_files: usize,
|
||||
}
|
||||
|
||||
pub async fn resolve_model_identifier(identifier: &str) -> Result<Vec<PathBuf>> {
|
||||
match parse_delete_model_ref(identifier).await? {
|
||||
DeleteModelRef::LocalStem(stem) => {
|
||||
let path = crate::models::find_model_path(&stem);
|
||||
if !path.exists() {
|
||||
bail!("Model not found: {}", identifier);
|
||||
}
|
||||
let mut resolved = BTreeSet::from([normalize_path(&path)]);
|
||||
if let Some(cache_info) = scan_hf_cache_info(&huggingface_hub_cache_dir()) {
|
||||
resolved.extend(find_related_hf_cache_paths(&cache_info, &path));
|
||||
}
|
||||
Ok(resolved.into_iter().collect())
|
||||
}
|
||||
DeleteModelRef::HuggingFace {
|
||||
repo,
|
||||
revision,
|
||||
file,
|
||||
} => resolve_cached_hf_ref(&repo, revision.as_deref(), &file)
|
||||
.await
|
||||
.with_context(|| format!("Resolve installed model ref {identifier}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_gguf_stem(stem: &str) -> &str {
|
||||
let stem = stem.strip_suffix(".gguf").unwrap_or(stem);
|
||||
split_gguf_base_name(stem).unwrap_or(stem)
|
||||
}
|
||||
|
||||
async fn resolve_cached_hf_ref(
|
||||
repo_id: &str,
|
||||
revision: Option<&str>,
|
||||
file: &str,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
let cache_root = huggingface_hub_cache_dir();
|
||||
let Some(cache_info) = scan_hf_cache_info(&cache_root) else {
|
||||
bail!("Model not found: {repo_id}");
|
||||
};
|
||||
|
||||
for repo in &cache_info.repos {
|
||||
if repo.repo_type != RepoTypeModel.singular() || repo.repo_id != repo_id {
|
||||
continue;
|
||||
}
|
||||
for cached_revision in &repo.revisions {
|
||||
if revision.is_some_and(|requested| {
|
||||
requested != cached_revision.commit_hash
|
||||
&& !cached_revision.refs.iter().any(|r| r == requested)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if file.is_empty() && repo_id.ends_with("-layers") {
|
||||
if cached_revision
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| is_layered_package_gguf_artifact(cached_revision, file))
|
||||
{
|
||||
let matches = layered_package_owned_paths(cached_revision);
|
||||
return Ok(matches);
|
||||
}
|
||||
bail!("Delete only supports GGUF models: {repo_id}");
|
||||
}
|
||||
let sibling_entries: Vec<(String, Option<u64>)> = cached_revision
|
||||
.files
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let size = std::fs::metadata(&entry.file_path)
|
||||
.ok()
|
||||
.map(|meta| meta.len());
|
||||
(entry.file_name.clone(), size)
|
||||
})
|
||||
.collect();
|
||||
let resolved_file = resolve_huggingface_file_from_sibling_entries(
|
||||
repo_id,
|
||||
revision.or_else(|| cached_revision.refs.first().map(String::as_str)),
|
||||
file,
|
||||
&sibling_entries,
|
||||
)
|
||||
.await?;
|
||||
if !resolved_file.ends_with(".gguf") {
|
||||
bail!("Delete only supports GGUF models: {repo_id}");
|
||||
}
|
||||
let expected = normalized_gguf_stem(&resolved_file);
|
||||
let mut matches: Vec<PathBuf> = cached_revision
|
||||
.files
|
||||
.iter()
|
||||
.filter(|entry| entry.file_name.ends_with(".gguf"))
|
||||
.filter(|entry| {
|
||||
normalized_gguf_stem(&entry.file_name).eq_ignore_ascii_case(expected)
|
||||
})
|
||||
.map(|entry| entry.file_path.clone())
|
||||
.collect();
|
||||
if !matches.is_empty() {
|
||||
matches.sort();
|
||||
return Ok(matches);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Model not found: {repo_id}")
|
||||
}
|
||||
|
||||
fn layered_package_owned_paths(revision: &CachedRevisionInfo) -> Vec<PathBuf> {
|
||||
let mut matches: Vec<PathBuf> = revision
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| file.file_path.clone())
|
||||
.collect();
|
||||
matches.sort();
|
||||
matches
|
||||
}
|
||||
|
||||
fn is_layered_package_gguf_artifact(revision: &CachedRevisionInfo, file: &CachedFileInfo) -> bool {
|
||||
let relative = file
|
||||
.file_path
|
||||
.strip_prefix(&revision.snapshot_path)
|
||||
.unwrap_or(file.file_path.as_path())
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
(relative.starts_with("shared/") || relative.starts_with("layers/"))
|
||||
&& relative.ends_with(".gguf")
|
||||
}
|
||||
|
||||
fn find_related_hf_cache_paths(cache_info: &HFCacheInfo, path: &Path) -> Vec<PathBuf> {
|
||||
let mut results = BTreeSet::new();
|
||||
let Some(identity) = huggingface_identity_for_path(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(file_name) = Path::new(&identity.file)
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let expected = normalized_gguf_stem(file_name);
|
||||
|
||||
for repo in &cache_info.repos {
|
||||
if repo.repo_type != RepoTypeModel.singular() || repo.repo_id != identity.repo_id {
|
||||
continue;
|
||||
}
|
||||
for revision in &repo.revisions {
|
||||
if revision.commit_hash != identity.revision {
|
||||
continue;
|
||||
}
|
||||
for file in &revision.files {
|
||||
if !file.file_name.ends_with(".gguf") {
|
||||
continue;
|
||||
}
|
||||
if normalized_gguf_stem(&file.file_name).eq_ignore_ascii_case(expected) {
|
||||
results.insert(file.file_path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.into_iter().collect()
|
||||
}
|
||||
|
||||
pub fn collect_delete_paths(resolved_paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
|
||||
let mut to_delete: BTreeSet<PathBuf> = BTreeSet::new();
|
||||
if resolved_paths.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
for path in resolved_paths {
|
||||
ensure_delete_path_allowed(path)?;
|
||||
to_delete.insert(normalize_path(path));
|
||||
}
|
||||
|
||||
let primary_path = &resolved_paths[0];
|
||||
if let Some(record) = usage::load_model_usage_record_for_path(primary_path)
|
||||
&& record.mesh_managed
|
||||
&& !record.managed_paths.is_empty()
|
||||
{
|
||||
for p in &record.managed_paths {
|
||||
to_delete.insert(normalize_path(p));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(to_delete.into_iter().collect())
|
||||
}
|
||||
|
||||
pub async fn delete_model_by_identifier(identifier: &str) -> Result<DeleteResult> {
|
||||
let resolved_paths = resolve_model_identifier(identifier).await?;
|
||||
|
||||
if resolved_paths.is_empty() {
|
||||
bail!("Model not found: {}", identifier);
|
||||
}
|
||||
|
||||
let all_paths = collect_delete_paths(&resolved_paths)?;
|
||||
|
||||
if all_paths.is_empty() {
|
||||
bail!(
|
||||
"No GGUF files found at resolved path: {}",
|
||||
resolved_paths[0].display()
|
||||
);
|
||||
}
|
||||
|
||||
let mut reclaimed_bytes: u64 = 0;
|
||||
let mut removed_metadata_files: usize = 0;
|
||||
let mut removed_usage_records: usize = 0;
|
||||
let mut deleted_paths: Vec<PathBuf> = Vec::new();
|
||||
let mut removed_record_paths = BTreeSet::new();
|
||||
|
||||
for path in &all_paths {
|
||||
if path.exists() {
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
reclaimed_bytes += meta.len();
|
||||
}
|
||||
std::fs::remove_file(path).with_context(|| format!("Remove {}", path.display()))?;
|
||||
deleted_paths.push(path.clone());
|
||||
|
||||
if let Some(metadata_path) = gguf_metadata_cache_path(path)
|
||||
&& metadata_path.exists()
|
||||
{
|
||||
std::fs::remove_file(&metadata_path).with_context(|| {
|
||||
format!("Remove metadata cache {}", metadata_path.display())
|
||||
})?;
|
||||
removed_metadata_files += 1;
|
||||
}
|
||||
|
||||
prune_empty_ancestors(path, &huggingface_hub_cache_dir());
|
||||
}
|
||||
}
|
||||
|
||||
for path in &all_paths {
|
||||
if let Some(record) = load_model_usage_record_for_path(path) {
|
||||
let usage_dir = usage::model_usage_cache_dir();
|
||||
let record_path = usage::usage_record_path(&usage_dir, &record.lookup_key);
|
||||
if removed_record_paths.insert(record_path.clone()) && record_path.exists() {
|
||||
std::fs::remove_file(&record_path)
|
||||
.with_context(|| format!("Remove usage record {}", record_path.display()))?;
|
||||
removed_usage_records += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DeleteResult {
|
||||
deleted_paths,
|
||||
reclaimed_bytes,
|
||||
removed_metadata_files,
|
||||
removed_usage_records,
|
||||
removed_derived_cache_files: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a model usage record for a given path.
|
||||
fn load_model_usage_record_for_path(path: &std::path::Path) -> Option<usage::ModelUsageRecord> {
|
||||
usage::load_model_usage_record_for_path(path)
|
||||
}
|
||||
|
||||
fn ensure_delete_path_allowed(path: &Path) -> Result<()> {
|
||||
let normalized = normalize_path(path);
|
||||
let hf_root = normalize_path(&huggingface_hub_cache_dir());
|
||||
let mesh_root = normalize_path(&mesh_llm_cache_dir());
|
||||
if normalized.starts_with(&hf_root) || normalized.starts_with(&mesh_root) {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"Deletion target outside known model roots: {}",
|
||||
normalized.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Prune empty ancestor directories up to (but not including) stop_at.
|
||||
fn prune_empty_ancestors(path: &std::path::Path, stop_at: &std::path::Path) {
|
||||
let stop_at = normalize_path(stop_at);
|
||||
let mut current = path.parent().map(normalize_path);
|
||||
while let Some(dir) = current {
|
||||
if dir == stop_at {
|
||||
break;
|
||||
}
|
||||
let Ok(mut entries) = std::fs::read_dir(&dir) else {
|
||||
break;
|
||||
};
|
||||
if entries.next().is_some() {
|
||||
break;
|
||||
}
|
||||
if std::fs::remove_dir(&dir).is_err() {
|
||||
break;
|
||||
}
|
||||
current = dir.parent().map(normalize_path);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_path(path: &std::path::Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
pub use model_hf::store::delete::DeleteResult;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
pub use model_hf::store::delete::resolve_huggingface_file_from_sibling_entries;
|
||||
|
||||
#[test]
|
||||
fn normalized_gguf_stem_collapses_split_shards() {
|
||||
assert_eq!(
|
||||
normalized_gguf_stem("GLM-5-UD-IQ2_XXS-00001-of-00006.gguf"),
|
||||
"GLM-5-UD-IQ2_XXS"
|
||||
);
|
||||
assert_eq!(normalized_gguf_stem("Qwen3-8B-Q4_K_M"), "Qwen3-8B-Q4_K_M");
|
||||
struct HostDeleteCatalog;
|
||||
|
||||
impl DeleteModelCatalog for HostDeleteCatalog {
|
||||
fn local_stem_for_identifier(&self, identifier: &str) -> Option<String> {
|
||||
crate::models::remote_catalog::find_model_exact(identifier)
|
||||
.map(|model| model.file.trim_end_matches(".gguf").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_model_identifier(identifier: &str) -> anyhow::Result<Vec<std::path::PathBuf>> {
|
||||
model_hf::store::delete::resolve_model_identifier_with_catalog(identifier, &HostDeleteCatalog)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_model_by_identifier(identifier: &str) -> anyhow::Result<DeleteResult> {
|
||||
model_hf::store::delete::delete_model_by_identifier_with_catalog(identifier, &HostDeleteCatalog)
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use serial_test::serial;
|
||||
|
||||
use crate::models::delete::{delete_model_by_identifier, resolve_model_identifier};
|
||||
use crate::models::resolve::resolve_huggingface_file_from_sibling_entries;
|
||||
use crate::models::delete::{
|
||||
delete_model_by_identifier, resolve_huggingface_file_from_sibling_entries,
|
||||
resolve_model_identifier,
|
||||
};
|
||||
|
||||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||
let stamp = std::time::SystemTime::now()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
use super::{build_hf_api, huggingface_hub_cache_dir, run_hf_sync, short_revision};
|
||||
use crate::cli::terminal_progress::{DeterminateProgressLine, clear_stderr_line};
|
||||
use anyhow::{Context, Result};
|
||||
use hf_hub::{RepoTypeModel, repository::ModelInfo};
|
||||
use mesh_llm_events::terminal_progress::{DeterminateProgressLine, clear_stderr_line};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,15 +8,15 @@ use std::{
|
|||
collections::HashSet,
|
||||
fs,
|
||||
path::{Component, Path, PathBuf},
|
||||
sync::{Mutex, RwLock},
|
||||
sync::{
|
||||
Mutex, RwLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, Instant, SystemTime},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::sync::{
|
||||
Arc, LazyLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use model_resolver::{
|
||||
|
|
@ -55,7 +55,6 @@ static CATALOG_REFRESH_BACKOFF_UNTIL: Mutex<Option<Instant>> = Mutex::new(None);
|
|||
/// cached catalog is already available.
|
||||
const CATALOG_REFRESH_BACKOFF: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
#[cfg(test)]
|
||||
static CATALOG_ENTRIES_OVERRIDE_ACTIVE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -65,8 +64,8 @@ type HfModelFileProbe = Arc<dyn Fn(&str, &str, &str) -> bool + Send + Sync>;
|
|||
static HF_MODEL_FILE_PROBE_OVERRIDE: LazyLock<Mutex<Option<HfModelFileProbe>>> =
|
||||
LazyLock::new(|| Mutex::new(None));
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct CatalogEntriesOverrideGuard {
|
||||
#[doc(hidden)]
|
||||
pub struct CatalogEntriesOverrideGuard {
|
||||
previous_entries: Option<Vec<CatalogEntry>>,
|
||||
previous_override_active: bool,
|
||||
}
|
||||
|
|
@ -76,10 +75,8 @@ pub(crate) struct HfModelFileProbeOverrideGuard {
|
|||
previous_probe: Option<HfModelFileProbe>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_catalog_entries_for_test(
|
||||
entries: Vec<CatalogEntry>,
|
||||
) -> CatalogEntriesOverrideGuard {
|
||||
#[doc(hidden)]
|
||||
pub fn set_catalog_entries_for_test(entries: Vec<CatalogEntry>) -> CatalogEntriesOverrideGuard {
|
||||
let previous_override_active = CATALOG_ENTRIES_OVERRIDE_ACTIVE.swap(true, Ordering::SeqCst);
|
||||
let mut lock = CATALOG_ENTRIES.write().unwrap();
|
||||
let previous = lock.replace(entries);
|
||||
|
|
@ -89,7 +86,6 @@ pub(crate) fn set_catalog_entries_for_test(
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for CatalogEntriesOverrideGuard {
|
||||
fn drop(&mut self) {
|
||||
*CATALOG_ENTRIES.write().unwrap() = self.previous_entries.take();
|
||||
|
|
@ -245,15 +241,12 @@ pub fn load_catalog_from_disk() -> Result<()> {
|
|||
|
||||
/// Ensures the catalog is loaded — refreshes if stale, otherwise loads from disk.
|
||||
pub fn ensure_catalog() -> Result<()> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if CATALOG_ENTRIES_OVERRIDE_ACTIVE.load(Ordering::SeqCst) {
|
||||
let lock = CATALOG_ENTRIES
|
||||
.read()
|
||||
.map_err(|_| anyhow::anyhow!("catalog lock poisoned"))?;
|
||||
if lock.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if CATALOG_ENTRIES_OVERRIDE_ACTIVE.load(Ordering::SeqCst) {
|
||||
let lock = CATALOG_ENTRIES
|
||||
.read()
|
||||
.map_err(|_| anyhow::anyhow!("catalog lock poisoned"))?;
|
||||
if lock.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use super::{
|
|||
capabilities, catalog, find_model_path, format_size_bytes, huggingface_identity_for_path,
|
||||
remote_catalog, track_model_usage,
|
||||
};
|
||||
use crate::cli::terminal_progress::start_spinner;
|
||||
use crate::models::usage::ModelUsageRecord;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use mesh_llm_events::terminal_progress::start_spinner;
|
||||
use model_artifact::{ModelArtifactFile, select_primary_artifact_file};
|
||||
use serde::Deserialize;
|
||||
use std::cmp::Ordering;
|
||||
|
|
@ -56,16 +56,6 @@ enum ExactModelRef {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum DeleteModelRef {
|
||||
LocalStem(String),
|
||||
HuggingFace {
|
||||
repo: String,
|
||||
revision: Option<String>,
|
||||
file: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn merge_capabilities(
|
||||
left: ModelCapabilities,
|
||||
right: ModelCapabilities,
|
||||
|
|
@ -452,50 +442,6 @@ pub fn installed_model_huggingface_ref(identity: &HuggingFaceModelIdentity) -> S
|
|||
format_huggingface_display_ref(&identity.repo_id, None, &identity.file)
|
||||
}
|
||||
|
||||
pub(crate) async fn parse_delete_model_ref(input: &str) -> Result<DeleteModelRef> {
|
||||
if input.starts_with("http://") || input.starts_with("https://") {
|
||||
bail!("Delete does not support direct URLs. Use a model stem or Hugging Face ref.");
|
||||
}
|
||||
if Path::new(input).is_absolute()
|
||||
|| input.contains('\\')
|
||||
|| input.starts_with("./")
|
||||
|| input.starts_with("../")
|
||||
|| input.starts_with("~/")
|
||||
{
|
||||
bail!("Delete does not support filesystem paths. Use a model stem or Hugging Face ref.");
|
||||
}
|
||||
|
||||
if let Some(model) = find_remote_catalog_model_exact(input) {
|
||||
let stem = model.file.trim_end_matches(".gguf");
|
||||
if find_model_path(stem).exists() {
|
||||
return Ok(DeleteModelRef::LocalStem(stem.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if !input.contains('/') {
|
||||
let installed_name = input.strip_suffix(".gguf").unwrap_or(input);
|
||||
if find_model_path(installed_name).exists() {
|
||||
return Ok(DeleteModelRef::LocalStem(installed_name.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let canonical = canonicalize_model_ref_input(input).await?;
|
||||
match parse_exact_model_ref(&canonical)? {
|
||||
ExactModelRef::Catalog(model) => Ok(DeleteModelRef::LocalStem(
|
||||
model.file.trim_end_matches(".gguf").to_string(),
|
||||
)),
|
||||
ExactModelRef::HuggingFace {
|
||||
repo,
|
||||
revision,
|
||||
file,
|
||||
} => Ok(DeleteModelRef::HuggingFace {
|
||||
repo,
|
||||
revision,
|
||||
file,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn matching_remote_catalog_model_for_huggingface(
|
||||
repo: &str,
|
||||
revision: Option<&str>,
|
||||
|
|
@ -537,9 +483,6 @@ fn parse_huggingface_repo_url(input: &str) -> Option<(String, Option<String>, Op
|
|||
}
|
||||
|
||||
fn parse_exact_model_ref(input: &str) -> Result<ExactModelRef> {
|
||||
if let Some(model) = find_remote_catalog_model_exact(input) {
|
||||
return Ok(ExactModelRef::Catalog(Box::new(model)));
|
||||
}
|
||||
if let Some((repo, revision, file)) = parse_huggingface_ref(input) {
|
||||
return Ok(ExactModelRef::HuggingFace {
|
||||
repo,
|
||||
|
|
@ -561,6 +504,9 @@ fn parse_exact_model_ref(input: &str) -> Result<ExactModelRef> {
|
|||
file: selector.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
if let Some(model) = find_remote_catalog_model_exact(input) {
|
||||
return Ok(ExactModelRef::Catalog(Box::new(model)));
|
||||
}
|
||||
bail!(
|
||||
"Expected an exact model ref. Use a catalog id or a Hugging Face ref like org/repo, org/repo@rev:QUANT, org/repo/file.gguf, org/repo/file-stem for split GGUFs, org/repo/model.safetensors, or org/repo/model-00001-of-00048.safetensors."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,968 +1 @@
|
|||
use super::local::{
|
||||
gguf_metadata_cache_path, huggingface_hub_cache_dir, huggingface_identity_for_path,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
|
||||
pub struct ModelUsageRecord {
|
||||
pub lookup_key: String,
|
||||
pub display_name: String,
|
||||
pub model_ref: Option<String>,
|
||||
pub source: String,
|
||||
pub mesh_managed: bool,
|
||||
pub primary_path: PathBuf,
|
||||
pub managed_paths: Vec<PathBuf>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hf_repo_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hf_revision: Option<String>,
|
||||
pub first_seen_at: String,
|
||||
pub last_used_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ModelCleanupCandidate {
|
||||
pub display_name: String,
|
||||
pub model_ref: Option<String>,
|
||||
pub source: String,
|
||||
pub primary_path: PathBuf,
|
||||
pub mesh_managed: bool,
|
||||
pub last_used_at: String,
|
||||
pub file_count: usize,
|
||||
pub total_bytes: u64,
|
||||
pub stale_record_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ModelCleanupPlan {
|
||||
pub candidates: Vec<ModelCleanupCandidate>,
|
||||
pub total_files: usize,
|
||||
pub total_bytes: u64,
|
||||
pub skipped_recent: usize,
|
||||
pub stale_record_only: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ModelCleanupResult {
|
||||
pub removed_candidates: usize,
|
||||
pub removed_files: usize,
|
||||
pub removed_records: usize,
|
||||
pub removed_metadata_files: usize,
|
||||
pub reclaimed_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CleanupEntry {
|
||||
record: ModelUsageRecord,
|
||||
record_path: PathBuf,
|
||||
removable_paths: Vec<PathBuf>,
|
||||
total_bytes: u64,
|
||||
stale_record_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct RecordLocation {
|
||||
lookup_key: String,
|
||||
record_path: PathBuf,
|
||||
record: Option<ModelUsageRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct HuggingFaceRecordIdentity {
|
||||
repo_id: String,
|
||||
revision: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct PathHuggingFaceIdentity {
|
||||
repo_id: String,
|
||||
revision: String,
|
||||
canonical_ref: String,
|
||||
}
|
||||
|
||||
pub fn model_usage_cache_dir() -> PathBuf {
|
||||
super::mesh_llm_cache_dir().join("model-usage")
|
||||
}
|
||||
|
||||
pub fn load_model_usage_record_for_path(path: &Path) -> Option<ModelUsageRecord> {
|
||||
let usage_dir = model_usage_cache_dir();
|
||||
let root = huggingface_hub_cache_dir();
|
||||
let lookup_key = usage_lookup_key(path, &root)?;
|
||||
resolve_record_location(&usage_dir, &lookup_key, &[normalize_path(path)]).record
|
||||
}
|
||||
|
||||
pub fn track_model_usage(
|
||||
path: &Path,
|
||||
display_name: Option<&str>,
|
||||
model_ref: Option<&str>,
|
||||
source: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let usage_dir = model_usage_cache_dir();
|
||||
let root = huggingface_hub_cache_dir();
|
||||
record_model_usage_in_dir(
|
||||
&usage_dir,
|
||||
&root,
|
||||
path,
|
||||
&[],
|
||||
display_name,
|
||||
model_ref,
|
||||
source,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn track_managed_model_usage(
|
||||
primary_path: &Path,
|
||||
managed_paths: &[PathBuf],
|
||||
display_name: &str,
|
||||
model_ref: Option<&str>,
|
||||
source: &str,
|
||||
) -> Result<()> {
|
||||
let usage_dir = model_usage_cache_dir();
|
||||
let root = huggingface_hub_cache_dir();
|
||||
record_model_usage_in_dir(
|
||||
&usage_dir,
|
||||
&root,
|
||||
primary_path,
|
||||
managed_paths,
|
||||
Some(display_name),
|
||||
model_ref,
|
||||
Some(source),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn plan_model_cleanup(unused_since: Option<Duration>) -> Result<ModelCleanupPlan> {
|
||||
let usage_dir = model_usage_cache_dir();
|
||||
let root = huggingface_hub_cache_dir();
|
||||
plan_model_cleanup_in_dir(&usage_dir, &root, unused_since)
|
||||
}
|
||||
|
||||
pub fn execute_model_cleanup(unused_since: Option<Duration>) -> Result<ModelCleanupResult> {
|
||||
let usage_dir = model_usage_cache_dir();
|
||||
let root = huggingface_hub_cache_dir();
|
||||
let records = load_model_usage_records_from_dir(&usage_dir);
|
||||
let cutoff = unused_since
|
||||
.map(ChronoDuration::from_std)
|
||||
.transpose()?
|
||||
.map(|age| Utc::now() - age);
|
||||
let mut skipped_recent = 0usize;
|
||||
let entries = plan_cleanup_entries(records, &usage_dir, &root, cutoff, &mut skipped_recent);
|
||||
execute_model_cleanup_entries(entries)
|
||||
}
|
||||
|
||||
fn load_model_usage_records_from_dir(dir: &Path) -> Vec<ModelUsageRecord> {
|
||||
let mut records = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return records;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
if let Some(record) = read_usage_record(&path) {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
fn read_usage_record(path: &Path) -> Option<ModelUsageRecord> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn record_model_usage_in_dir(
|
||||
usage_dir: &Path,
|
||||
track_root: &Path,
|
||||
path: &Path,
|
||||
managed_paths: &[PathBuf],
|
||||
display_name: Option<&str>,
|
||||
model_ref: Option<&str>,
|
||||
source: Option<&str>,
|
||||
mesh_managed: bool,
|
||||
) -> Result<()> {
|
||||
let Some(lookup_key) = usage_lookup_key(path, track_root) else {
|
||||
return Ok(());
|
||||
};
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let primary_path = normalize_path(path);
|
||||
let normalized_managed_paths = unique_paths(managed_paths.to_vec());
|
||||
let candidate_paths = usage_record_candidate_paths(&primary_path, &normalized_managed_paths);
|
||||
let location = resolve_record_location(usage_dir, &lookup_key, &candidate_paths);
|
||||
let record_path = location.record_path;
|
||||
let existing = location.record;
|
||||
let existing_display_name = existing
|
||||
.as_ref()
|
||||
.map(|record| record.display_name.as_str())
|
||||
.filter(|value| !value.is_empty());
|
||||
let existing_source = existing
|
||||
.as_ref()
|
||||
.map(|record| record.source.as_str())
|
||||
.filter(|value| !value.is_empty());
|
||||
let existing_model_ref = existing
|
||||
.as_ref()
|
||||
.and_then(|record| record.model_ref.as_deref());
|
||||
|
||||
let mut merged_paths = existing
|
||||
.as_ref()
|
||||
.map(|record| record.managed_paths.clone())
|
||||
.unwrap_or_default();
|
||||
if mesh_managed {
|
||||
if normalized_managed_paths.is_empty() {
|
||||
merged_paths.push(primary_path.clone());
|
||||
} else {
|
||||
merged_paths.extend(normalized_managed_paths.iter().cloned());
|
||||
}
|
||||
}
|
||||
merged_paths = unique_paths(merged_paths);
|
||||
let hf_identity = infer_record_hf_identity(&primary_path, &merged_paths, track_root)
|
||||
.or_else(|| existing.as_ref().and_then(record_hf_identity));
|
||||
|
||||
let record = ModelUsageRecord {
|
||||
lookup_key: location.lookup_key,
|
||||
display_name: display_name
|
||||
.or(existing_display_name)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| default_display_name(&primary_path)),
|
||||
model_ref: model_ref
|
||||
.or(existing_model_ref)
|
||||
.map(str::to_string)
|
||||
.or_else(|| default_model_ref(&primary_path)),
|
||||
source: source
|
||||
.or(existing_source)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| default_source(&primary_path)),
|
||||
mesh_managed: mesh_managed || existing.as_ref().is_some_and(|record| record.mesh_managed),
|
||||
primary_path,
|
||||
managed_paths: merged_paths,
|
||||
hf_repo_id: hf_identity
|
||||
.as_ref()
|
||||
.map(|identity| identity.repo_id.clone()),
|
||||
hf_revision: hf_identity
|
||||
.as_ref()
|
||||
.map(|identity| identity.revision.clone()),
|
||||
first_seen_at: existing
|
||||
.as_ref()
|
||||
.map(|record| record.first_seen_at.clone())
|
||||
.unwrap_or_else(|| now.clone()),
|
||||
last_used_at: now,
|
||||
};
|
||||
|
||||
std::fs::create_dir_all(usage_dir)
|
||||
.with_context(|| format!("Create {}", usage_dir.display()))?;
|
||||
let bytes = serde_json::to_vec_pretty(&record)?;
|
||||
std::fs::write(&record_path, bytes)
|
||||
.with_context(|| format!("Write {}", record_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plan_model_cleanup_in_dir(
|
||||
usage_dir: &Path,
|
||||
track_root: &Path,
|
||||
unused_since: Option<Duration>,
|
||||
) -> Result<ModelCleanupPlan> {
|
||||
let records = load_model_usage_records_from_dir(usage_dir);
|
||||
let cutoff = unused_since
|
||||
.map(ChronoDuration::from_std)
|
||||
.transpose()?
|
||||
.map(|age| Utc::now() - age);
|
||||
let mut skipped_recent = 0usize;
|
||||
let entries = plan_cleanup_entries(records, usage_dir, track_root, cutoff, &mut skipped_recent);
|
||||
let mut plan = ModelCleanupPlan {
|
||||
skipped_recent,
|
||||
..Default::default()
|
||||
};
|
||||
for entry in entries {
|
||||
if entry.stale_record_only {
|
||||
plan.stale_record_only += 1;
|
||||
}
|
||||
plan.total_files += entry.removable_paths.len();
|
||||
plan.total_bytes += entry.total_bytes;
|
||||
plan.candidates.push(ModelCleanupCandidate {
|
||||
display_name: entry.record.display_name,
|
||||
model_ref: entry.record.model_ref,
|
||||
source: entry.record.source,
|
||||
primary_path: entry.record.primary_path,
|
||||
mesh_managed: entry.record.mesh_managed,
|
||||
last_used_at: entry.record.last_used_at,
|
||||
file_count: entry.removable_paths.len(),
|
||||
total_bytes: entry.total_bytes,
|
||||
stale_record_only: entry.stale_record_only,
|
||||
});
|
||||
}
|
||||
plan.candidates.sort_by(|left, right| {
|
||||
left.last_used_at
|
||||
.cmp(&right.last_used_at)
|
||||
.then_with(|| left.display_name.cmp(&right.display_name))
|
||||
});
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
fn plan_cleanup_entries(
|
||||
records: Vec<ModelUsageRecord>,
|
||||
usage_dir: &Path,
|
||||
track_root: &Path,
|
||||
cutoff: Option<DateTime<Utc>>,
|
||||
skipped_recent: &mut usize,
|
||||
) -> Vec<CleanupEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for record in records {
|
||||
if !record.mesh_managed {
|
||||
continue;
|
||||
}
|
||||
let last_used =
|
||||
parse_timestamp(&record.last_used_at).unwrap_or(DateTime::<Utc>::UNIX_EPOCH);
|
||||
if let Some(cutoff) = cutoff
|
||||
&& last_used > cutoff
|
||||
{
|
||||
*skipped_recent += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let removable_paths: Vec<PathBuf> = unique_paths(record.managed_paths.clone())
|
||||
.into_iter()
|
||||
.filter(|path| is_trackable_path(path, track_root))
|
||||
.filter(|path| path_matches_record_identity(&record, path, track_root))
|
||||
.filter(|path| path.exists())
|
||||
.collect();
|
||||
let total_bytes = removable_paths
|
||||
.iter()
|
||||
.filter_map(|path| std::fs::metadata(path).ok().map(|meta| meta.len()))
|
||||
.sum();
|
||||
let stale_record_only = removable_paths.is_empty();
|
||||
let record_path = usage_record_path(usage_dir, &record.lookup_key);
|
||||
|
||||
entries.push(CleanupEntry {
|
||||
record,
|
||||
record_path,
|
||||
removable_paths,
|
||||
total_bytes,
|
||||
stale_record_only,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|left, right| {
|
||||
left.record
|
||||
.last_used_at
|
||||
.cmp(&right.record.last_used_at)
|
||||
.then_with(|| left.record.display_name.cmp(&right.record.display_name))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
fn execute_model_cleanup_entries(entries: Vec<CleanupEntry>) -> Result<ModelCleanupResult> {
|
||||
let mut result = ModelCleanupResult::default();
|
||||
for entry in entries {
|
||||
for path in &entry.removable_paths {
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
result.reclaimed_bytes += meta.len();
|
||||
}
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path).with_context(|| format!("Remove {}", path.display()))?;
|
||||
result.removed_files += 1;
|
||||
}
|
||||
if let Some(cache_path) = gguf_metadata_cache_path(path)
|
||||
&& cache_path.exists()
|
||||
{
|
||||
std::fs::remove_file(&cache_path)
|
||||
.with_context(|| format!("Remove metadata cache {}", cache_path.display()))?;
|
||||
result.removed_metadata_files += 1;
|
||||
}
|
||||
prune_empty_ancestors(path, &huggingface_hub_cache_dir());
|
||||
}
|
||||
if entry.record_path.exists() {
|
||||
std::fs::remove_file(&entry.record_path)
|
||||
.with_context(|| format!("Remove {}", entry.record_path.display()))?;
|
||||
result.removed_records += 1;
|
||||
}
|
||||
result.removed_candidates += 1;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn usage_lookup_key(path: &Path, track_root: &Path) -> Option<String> {
|
||||
if !is_trackable_path(path, track_root) {
|
||||
return None;
|
||||
}
|
||||
if let Some(identity) = hf_identity_for_path_in_root(path, track_root) {
|
||||
return Some(format!("hf:{}", identity.canonical_ref));
|
||||
}
|
||||
if let Some(identity) = huggingface_identity_for_path(path) {
|
||||
return Some(format!("hf:{}", identity.canonical_ref));
|
||||
}
|
||||
Some(format!(
|
||||
"path:{}",
|
||||
normalize_path(path).to_string_lossy().replace('\\', "/")
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_record_location(
|
||||
usage_dir: &Path,
|
||||
direct_lookup_key: &str,
|
||||
candidate_paths: &[PathBuf],
|
||||
) -> RecordLocation {
|
||||
let direct_record_path = usage_record_path(usage_dir, direct_lookup_key);
|
||||
if let Some(record) = read_usage_record(&direct_record_path) {
|
||||
return RecordLocation {
|
||||
lookup_key: direct_lookup_key.to_string(),
|
||||
record_path: direct_record_path,
|
||||
record: Some(record),
|
||||
};
|
||||
}
|
||||
|
||||
let Some(existing) = find_usage_record_by_paths(usage_dir, candidate_paths) else {
|
||||
return RecordLocation {
|
||||
lookup_key: direct_lookup_key.to_string(),
|
||||
record_path: direct_record_path,
|
||||
record: None,
|
||||
};
|
||||
};
|
||||
|
||||
let record_path = usage_record_path(usage_dir, &existing.lookup_key);
|
||||
RecordLocation {
|
||||
lookup_key: existing.lookup_key.clone(),
|
||||
record_path,
|
||||
record: Some(existing),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_usage_record_by_paths(
|
||||
usage_dir: &Path,
|
||||
candidate_paths: &[PathBuf],
|
||||
) -> Option<ModelUsageRecord> {
|
||||
let candidate_paths = unique_paths(candidate_paths.to_vec());
|
||||
if candidate_paths.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate_set: HashSet<PathBuf> = candidate_paths.iter().cloned().collect();
|
||||
load_model_usage_records_from_dir(usage_dir)
|
||||
.into_iter()
|
||||
.find(|record| record_matches_any_path(record, &candidate_set))
|
||||
}
|
||||
|
||||
fn record_matches_any_path(record: &ModelUsageRecord, candidate_paths: &HashSet<PathBuf>) -> bool {
|
||||
let primary_path = normalize_path(&record.primary_path);
|
||||
if candidate_paths.contains(&primary_path) {
|
||||
return true;
|
||||
}
|
||||
if record
|
||||
.managed_paths
|
||||
.iter()
|
||||
.map(|path| normalize_path(path))
|
||||
.any(|path| candidate_paths.contains(&path))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn usage_record_candidate_paths(primary_path: &Path, managed_paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut paths = vec![primary_path.to_path_buf()];
|
||||
paths.extend(managed_paths.iter().cloned());
|
||||
unique_paths(paths)
|
||||
}
|
||||
|
||||
fn infer_record_hf_identity(
|
||||
primary_path: &Path,
|
||||
managed_paths: &[PathBuf],
|
||||
track_root: &Path,
|
||||
) -> Option<HuggingFaceRecordIdentity> {
|
||||
let mut paths = usage_record_candidate_paths(primary_path, managed_paths).into_iter();
|
||||
let first = paths
|
||||
.find_map(|path| hf_identity_for_path_in_root(&path, track_root))
|
||||
.map(|identity| HuggingFaceRecordIdentity {
|
||||
repo_id: identity.repo_id,
|
||||
revision: identity.revision,
|
||||
})?;
|
||||
|
||||
for path in usage_record_candidate_paths(primary_path, managed_paths) {
|
||||
let Some(identity) = hf_identity_for_path_in_root(&path, track_root) else {
|
||||
continue;
|
||||
};
|
||||
if identity.repo_id != first.repo_id || identity.revision != first.revision {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(first)
|
||||
}
|
||||
|
||||
fn record_hf_identity(record: &ModelUsageRecord) -> Option<HuggingFaceRecordIdentity> {
|
||||
Some(HuggingFaceRecordIdentity {
|
||||
repo_id: record.hf_repo_id.clone()?,
|
||||
revision: record.hf_revision.clone()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn path_matches_record_identity(record: &ModelUsageRecord, path: &Path, track_root: &Path) -> bool {
|
||||
let Some(record_identity) = record_hf_identity(record) else {
|
||||
return true;
|
||||
};
|
||||
hf_identity_for_path_in_root(path, track_root).is_some_and(|identity| {
|
||||
identity.repo_id == record_identity.repo_id && identity.revision == record_identity.revision
|
||||
})
|
||||
}
|
||||
|
||||
fn hf_identity_for_path_in_root(path: &Path, track_root: &Path) -> Option<PathHuggingFaceIdentity> {
|
||||
let path = normalize_path(path);
|
||||
let root = normalize_path(track_root);
|
||||
let relative = path.strip_prefix(&root).ok()?;
|
||||
let mut components = relative.components();
|
||||
let repo_dir = components.next()?.as_os_str().to_str()?;
|
||||
let repo_id = repo_dir.strip_prefix("models--")?.replace("--", "/");
|
||||
if components.next()?.as_os_str() != "snapshots" {
|
||||
return None;
|
||||
}
|
||||
let revision = components.next()?.as_os_str().to_str()?.to_string();
|
||||
let relative_file = components
|
||||
.map(|component| component.as_os_str().to_str())
|
||||
.collect::<Option<Vec<_>>>()?
|
||||
.join("/");
|
||||
if relative_file.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(PathHuggingFaceIdentity {
|
||||
repo_id: repo_id.clone(),
|
||||
revision: revision.clone(),
|
||||
canonical_ref: format!("{repo_id}@{revision}/{relative_file}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn usage_record_path(usage_dir: &Path, lookup_key: &str) -> PathBuf {
|
||||
let digest = Sha256::digest(lookup_key.as_bytes());
|
||||
usage_dir.join(format!("{digest:x}.json"))
|
||||
}
|
||||
|
||||
fn normalize_path(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn unique_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut unique = Vec::new();
|
||||
for path in paths {
|
||||
let normalized = normalize_path(&path);
|
||||
if seen.insert(normalized.clone()) {
|
||||
unique.push(normalized);
|
||||
}
|
||||
}
|
||||
unique.sort();
|
||||
unique
|
||||
}
|
||||
|
||||
fn parse_timestamp(value: &str) -> Option<DateTime<Utc>> {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.ok()
|
||||
.map(|parsed| parsed.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn default_display_name(path: &Path) -> String {
|
||||
path.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.or_else(|| path.file_name().and_then(|value| value.to_str()))
|
||||
.unwrap_or("model")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn default_model_ref(path: &Path) -> Option<String> {
|
||||
huggingface_identity_for_path(path).map(|identity| identity.canonical_ref)
|
||||
}
|
||||
|
||||
fn default_source(path: &Path) -> String {
|
||||
if huggingface_identity_for_path(path).is_some() {
|
||||
"huggingface-cache".to_string()
|
||||
} else {
|
||||
"local-cache".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_trackable_path(path: &Path, track_root: &Path) -> bool {
|
||||
let path = normalize_path(path);
|
||||
let root = normalize_path(track_root);
|
||||
path.starts_with(&root)
|
||||
}
|
||||
|
||||
fn prune_empty_ancestors(path: &Path, stop_at: &Path) {
|
||||
let stop_at = normalize_path(stop_at);
|
||||
let mut current = path.parent().map(normalize_path);
|
||||
while let Some(dir) = current {
|
||||
if dir == stop_at {
|
||||
break;
|
||||
}
|
||||
let Ok(mut entries) = std::fs::read_dir(&dir) else {
|
||||
break;
|
||||
};
|
||||
if entries.next().is_some() {
|
||||
break;
|
||||
}
|
||||
if std::fs::remove_dir(&dir).is_err() {
|
||||
break;
|
||||
}
|
||||
current = dir.parent().map(normalize_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn temp_dir(prefix: &str) -> PathBuf {
|
||||
let sequence = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"{prefix}-{}-{}-{}",
|
||||
std::process::id(),
|
||||
sequence,
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system time should be after epoch")
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
fn write_record(dir: &Path, record: &ModelUsageRecord) {
|
||||
std::fs::create_dir_all(dir).expect("usage dir should be created");
|
||||
let path = usage_record_path(dir, &record.lookup_key);
|
||||
std::fs::write(
|
||||
path,
|
||||
serde_json::to_vec_pretty(record).expect("record JSON should serialize"),
|
||||
)
|
||||
.expect("record should be written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_model_usage_merges_managed_paths() {
|
||||
let usage_dir = temp_dir("mesh-llm-usage-dir");
|
||||
let cache_root = temp_dir("mesh-llm-hf-cache");
|
||||
let primary = cache_root
|
||||
.join("models--Org--Demo")
|
||||
.join("snapshots")
|
||||
.join("rev1")
|
||||
.join("Demo-Q4_K_M.gguf");
|
||||
let shard = cache_root
|
||||
.join("models--Org--Demo")
|
||||
.join("snapshots")
|
||||
.join("rev1")
|
||||
.join("Demo-Q4_K_M-00002-of-00002.gguf");
|
||||
std::fs::create_dir_all(primary.parent().expect("primary path should have parent"))
|
||||
.expect("primary parent should exist");
|
||||
std::fs::write(&primary, b"primary").expect("primary model should be written");
|
||||
std::fs::write(&shard, b"shard").expect("shard model should be written");
|
||||
|
||||
record_model_usage_in_dir(
|
||||
&usage_dir,
|
||||
&cache_root,
|
||||
&primary,
|
||||
&[primary.clone(), shard.clone()],
|
||||
Some("Demo-Q4_K_M"),
|
||||
Some("Org/Demo@rev1/Demo-Q4_K_M.gguf"),
|
||||
Some("catalog"),
|
||||
true,
|
||||
)
|
||||
.expect("managed usage should be recorded");
|
||||
|
||||
let records = load_model_usage_records_from_dir(&usage_dir);
|
||||
assert_eq!(records.len(), 1);
|
||||
assert!(records[0].mesh_managed);
|
||||
assert_eq!(records[0].managed_paths.len(), 2);
|
||||
assert_eq!(records[0].display_name, "Demo-Q4_K_M");
|
||||
assert_eq!(records[0].hf_repo_id.as_deref(), Some("Org/Demo"));
|
||||
assert_eq!(records[0].hf_revision.as_deref(), Some("rev1"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&usage_dir);
|
||||
let _ = std::fs::remove_dir_all(&cache_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_model_usage_record_for_split_shard_returns_bundle_record() {
|
||||
let usage_dir = temp_dir("mesh-llm-usage-dir");
|
||||
let cache_root = temp_dir("mesh-llm-hf-cache");
|
||||
let primary = cache_root
|
||||
.join("models--Org--Bundle")
|
||||
.join("snapshots")
|
||||
.join("rev1")
|
||||
.join("Bundle-Q4_K_M-00001-of-00002.gguf");
|
||||
let shard = cache_root
|
||||
.join("models--Org--Bundle")
|
||||
.join("snapshots")
|
||||
.join("rev1")
|
||||
.join("Bundle-Q4_K_M-00002-of-00002.gguf");
|
||||
std::fs::create_dir_all(primary.parent().expect("primary path should have parent"))
|
||||
.expect("primary parent should exist");
|
||||
std::fs::write(&primary, b"primary").expect("primary model should be written");
|
||||
std::fs::write(&shard, b"shard").expect("shard model should be written");
|
||||
|
||||
record_model_usage_in_dir(
|
||||
&usage_dir,
|
||||
&cache_root,
|
||||
&primary,
|
||||
&[primary.clone(), shard.clone()],
|
||||
Some("Bundle-Q4_K_M"),
|
||||
Some("Org/Bundle@rev1/Bundle-Q4_K_M-00001-of-00002.gguf"),
|
||||
Some("catalog"),
|
||||
true,
|
||||
)
|
||||
.expect("managed usage should be recorded");
|
||||
|
||||
let record = find_usage_record_by_paths(&usage_dir, std::slice::from_ref(&shard))
|
||||
.expect("split shard should resolve back to the bundle record");
|
||||
assert_eq!(
|
||||
record.lookup_key,
|
||||
usage_lookup_key(&primary, &cache_root).expect("primary path should key")
|
||||
);
|
||||
assert_eq!(record.managed_paths.len(), 2);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&usage_dir);
|
||||
let _ = std::fs::remove_dir_all(&cache_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_model_usage_updates_last_used_at_by_managed_identity() {
|
||||
let usage_dir = temp_dir("mesh-llm-usage-dir");
|
||||
let cache_root = temp_dir("mesh-llm-hf-cache");
|
||||
let primary = cache_root
|
||||
.join("models--Org--Bundle")
|
||||
.join("snapshots")
|
||||
.join("rev1")
|
||||
.join("Bundle-Q4_K_M-00001-of-00002.gguf");
|
||||
let shard = cache_root
|
||||
.join("models--Org--Bundle")
|
||||
.join("snapshots")
|
||||
.join("rev1")
|
||||
.join("Bundle-Q4_K_M-00002-of-00002.gguf");
|
||||
std::fs::create_dir_all(primary.parent().expect("primary path should have parent"))
|
||||
.expect("primary parent should exist");
|
||||
std::fs::write(&primary, b"primary").expect("primary model should be written");
|
||||
std::fs::write(&shard, b"shard").expect("shard model should be written");
|
||||
|
||||
let lookup_key = usage_lookup_key(&primary, &cache_root).expect("primary path should key");
|
||||
write_record(
|
||||
&usage_dir,
|
||||
&ModelUsageRecord {
|
||||
lookup_key: lookup_key.clone(),
|
||||
display_name: "Bundle-Q4_K_M".to_string(),
|
||||
model_ref: Some("Org/Bundle@rev1/Bundle-Q4_K_M-00001-of-00002.gguf".to_string()),
|
||||
source: "catalog".to_string(),
|
||||
mesh_managed: true,
|
||||
primary_path: primary.clone(),
|
||||
managed_paths: vec![primary.clone(), shard.clone()],
|
||||
hf_repo_id: Some("Org/Bundle".to_string()),
|
||||
hf_revision: Some("rev1".to_string()),
|
||||
first_seen_at: "2026-04-01T00:00:00Z".to_string(),
|
||||
last_used_at: "2026-04-01T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
record_model_usage_in_dir(
|
||||
&usage_dir,
|
||||
&cache_root,
|
||||
&shard,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
Some("resolve"),
|
||||
false,
|
||||
)
|
||||
.expect("usage refresh should succeed");
|
||||
|
||||
let records = load_model_usage_records_from_dir(&usage_dir);
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].lookup_key, lookup_key);
|
||||
assert_eq!(records[0].managed_paths.len(), 2);
|
||||
assert_ne!(records[0].last_used_at, "2026-04-01T00:00:00Z");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&usage_dir);
|
||||
let _ = std::fs::remove_dir_all(&cache_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_plan_filters_recent_and_external_records() {
|
||||
let usage_dir = temp_dir("mesh-llm-usage-dir");
|
||||
let cache_root = temp_dir("mesh-llm-hf-cache");
|
||||
let old_path = cache_root
|
||||
.join("models--Org--Old")
|
||||
.join("snapshots")
|
||||
.join("rev1")
|
||||
.join("Old-Q4_K_M.gguf");
|
||||
let recent_path = cache_root
|
||||
.join("models--Org--Recent")
|
||||
.join("snapshots")
|
||||
.join("rev1")
|
||||
.join("Recent-Q4_K_M.gguf");
|
||||
let external_path = cache_root
|
||||
.join("models--Org--External")
|
||||
.join("snapshots")
|
||||
.join("rev1")
|
||||
.join("External-Q4_K_M.gguf");
|
||||
|
||||
for path in [&old_path, &recent_path, &external_path] {
|
||||
std::fs::create_dir_all(path.parent().expect("test path should have parent"))
|
||||
.expect("test parent should exist");
|
||||
std::fs::write(path, vec![0_u8; 16]).expect("test model should be written");
|
||||
}
|
||||
|
||||
write_record(
|
||||
&usage_dir,
|
||||
&ModelUsageRecord {
|
||||
lookup_key: usage_lookup_key(&old_path, &cache_root).expect("old path should key"),
|
||||
display_name: "Old".to_string(),
|
||||
model_ref: None,
|
||||
source: "catalog".to_string(),
|
||||
mesh_managed: true,
|
||||
primary_path: old_path.clone(),
|
||||
managed_paths: vec![old_path.clone()],
|
||||
hf_repo_id: Some("Org/Old".to_string()),
|
||||
hf_revision: Some("rev1".to_string()),
|
||||
first_seen_at: "2026-04-01T00:00:00Z".to_string(),
|
||||
last_used_at: "2026-04-01T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
write_record(
|
||||
&usage_dir,
|
||||
&ModelUsageRecord {
|
||||
lookup_key: usage_lookup_key(&recent_path, &cache_root)
|
||||
.expect("recent path should key"),
|
||||
display_name: "Recent".to_string(),
|
||||
model_ref: None,
|
||||
source: "catalog".to_string(),
|
||||
mesh_managed: true,
|
||||
primary_path: recent_path.clone(),
|
||||
managed_paths: vec![recent_path.clone()],
|
||||
hf_repo_id: Some("Org/Recent".to_string()),
|
||||
hf_revision: Some("rev1".to_string()),
|
||||
first_seen_at: Utc::now().to_rfc3339(),
|
||||
last_used_at: Utc::now().to_rfc3339(),
|
||||
},
|
||||
);
|
||||
write_record(
|
||||
&usage_dir,
|
||||
&ModelUsageRecord {
|
||||
lookup_key: usage_lookup_key(&external_path, &cache_root)
|
||||
.expect("external path should key"),
|
||||
display_name: "External".to_string(),
|
||||
model_ref: None,
|
||||
source: "local-cache".to_string(),
|
||||
mesh_managed: false,
|
||||
primary_path: external_path.clone(),
|
||||
managed_paths: vec![],
|
||||
hf_repo_id: Some("Org/External".to_string()),
|
||||
hf_revision: Some("rev1".to_string()),
|
||||
first_seen_at: "2026-04-01T00:00:00Z".to_string(),
|
||||
last_used_at: "2026-04-01T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let plan =
|
||||
plan_model_cleanup_in_dir(&usage_dir, &cache_root, Some(Duration::from_secs(60)))
|
||||
.expect("cleanup plan should succeed");
|
||||
assert_eq!(plan.candidates.len(), 1);
|
||||
assert_eq!(plan.candidates[0].display_name, "Old");
|
||||
assert_eq!(plan.skipped_recent, 1);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&usage_dir);
|
||||
let _ = std::fs::remove_dir_all(&cache_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_cleanup_removes_files_and_records() {
|
||||
let usage_dir = temp_dir("mesh-llm-usage-dir");
|
||||
let cache_root = temp_dir("mesh-llm-hf-cache");
|
||||
let primary = cache_root
|
||||
.join("models--Org--Cleanup")
|
||||
.join("snapshots")
|
||||
.join("rev1")
|
||||
.join("Cleanup-Q4_K_M.gguf");
|
||||
std::fs::create_dir_all(primary.parent().expect("cleanup path should have parent"))
|
||||
.expect("cleanup parent should exist");
|
||||
std::fs::write(&primary, vec![0_u8; 32]).expect("cleanup model should be written");
|
||||
|
||||
let record = ModelUsageRecord {
|
||||
lookup_key: usage_lookup_key(&primary, &cache_root).expect("cleanup path should key"),
|
||||
display_name: "Cleanup".to_string(),
|
||||
model_ref: Some("Org/Cleanup@rev1/Cleanup-Q4_K_M.gguf".to_string()),
|
||||
source: "catalog".to_string(),
|
||||
mesh_managed: true,
|
||||
primary_path: primary.clone(),
|
||||
managed_paths: vec![primary.clone()],
|
||||
hf_repo_id: Some("Org/Cleanup".to_string()),
|
||||
hf_revision: Some("rev1".to_string()),
|
||||
first_seen_at: "2026-04-01T00:00:00Z".to_string(),
|
||||
last_used_at: "2026-04-01T00:00:00Z".to_string(),
|
||||
};
|
||||
write_record(&usage_dir, &record);
|
||||
|
||||
let mut skipped_recent = 0usize;
|
||||
let entries = plan_cleanup_entries(
|
||||
vec![record],
|
||||
&usage_dir,
|
||||
&cache_root,
|
||||
None,
|
||||
&mut skipped_recent,
|
||||
);
|
||||
let result = execute_model_cleanup_entries(entries).expect("cleanup should succeed");
|
||||
assert_eq!(result.removed_candidates, 1);
|
||||
assert_eq!(result.removed_files, 1);
|
||||
assert_eq!(result.removed_records, 1);
|
||||
assert!(!primary.exists());
|
||||
assert!(load_model_usage_records_from_dir(&usage_dir).is_empty());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&usage_dir);
|
||||
let _ = std::fs::remove_dir_all(&cache_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_skips_paths_that_no_longer_match_record_identity() {
|
||||
let usage_dir = temp_dir("mesh-llm-usage-dir");
|
||||
let cache_root = temp_dir("mesh-llm-hf-cache");
|
||||
let old_path = cache_root
|
||||
.join("models--Org--Actual")
|
||||
.join("snapshots")
|
||||
.join("rev2")
|
||||
.join("Actual-Q4_K_M.gguf");
|
||||
std::fs::create_dir_all(old_path.parent().expect("cleanup path should have parent"))
|
||||
.expect("cleanup parent should exist");
|
||||
std::fs::write(&old_path, vec![0_u8; 32]).expect("cleanup model should be written");
|
||||
|
||||
let record = ModelUsageRecord {
|
||||
lookup_key: "hf:Org/Expected@rev1/Expected-Q4_K_M.gguf".to_string(),
|
||||
display_name: "Expected".to_string(),
|
||||
model_ref: Some("Org/Expected@rev1/Expected-Q4_K_M.gguf".to_string()),
|
||||
source: "catalog".to_string(),
|
||||
mesh_managed: true,
|
||||
primary_path: old_path.clone(),
|
||||
managed_paths: vec![old_path.clone()],
|
||||
hf_repo_id: Some("Org/Expected".to_string()),
|
||||
hf_revision: Some("rev1".to_string()),
|
||||
first_seen_at: "2026-04-01T00:00:00Z".to_string(),
|
||||
last_used_at: "2026-04-01T00:00:00Z".to_string(),
|
||||
};
|
||||
write_record(&usage_dir, &record);
|
||||
|
||||
let mut skipped_recent = 0usize;
|
||||
let entries = plan_cleanup_entries(
|
||||
vec![record],
|
||||
&usage_dir,
|
||||
&cache_root,
|
||||
None,
|
||||
&mut skipped_recent,
|
||||
);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(entries[0].removable_paths.is_empty());
|
||||
assert!(entries[0].stale_record_only);
|
||||
assert!(old_path.exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&usage_dir);
|
||||
let _ = std::fs::remove_dir_all(&cache_root);
|
||||
}
|
||||
}
|
||||
pub use model_hf::store::usage::*;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
use anyhow::{Context, Result};
|
||||
use clap::ValueEnum;
|
||||
use mdns_sd::{DaemonStatus, ResolvedService, ServiceDaemon, ServiceEvent, ServiceInfo};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) use crate::discovery::{DiscoveryScope, MeshDiscoveryMode};
|
||||
use crate::network::nostr;
|
||||
|
||||
pub(crate) const LAN_SERVICE_TYPE: &str = "_mesh-llm._tcp.local.";
|
||||
pub const LAN_SERVICE_TYPE: &str = "_mesh-llm._tcp.local.";
|
||||
pub(crate) const LAN_DETAILS_PATH: &str = "/api/discovery/lan-details";
|
||||
const TXT_SCHEMA_VERSION: u8 = 1;
|
||||
const TXT_LIST_SEPARATOR: char = '|';
|
||||
|
|
@ -19,55 +19,9 @@ const LAN_DETAILS_CHALLENGE_DOMAIN: &[u8] = b"mesh-llm-lan-details-challenge-v1\
|
|||
const LAN_DETAILS_TOKEN_PROOF_DOMAIN: &[u8] = b"mesh-llm-lan-details-proof-v1\0";
|
||||
const DAEMON_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
|
||||
pub(crate) enum MeshDiscoveryMode {
|
||||
#[default]
|
||||
Nostr,
|
||||
Mdns,
|
||||
}
|
||||
|
||||
impl MeshDiscoveryMode {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Nostr => "nostr",
|
||||
Self::Mdns => "mdns",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source(self) -> &'static str {
|
||||
match self {
|
||||
Self::Nostr => "nostr-relay",
|
||||
Self::Mdns => "mdns-sd",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scope(self) -> DiscoveryScope {
|
||||
match self {
|
||||
Self::Nostr => DiscoveryScope::Public,
|
||||
Self::Mdns => DiscoveryScope::Lan,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum DiscoveryScope {
|
||||
Public,
|
||||
Lan,
|
||||
}
|
||||
|
||||
impl DiscoveryScope {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Public => "public",
|
||||
Self::Lan => "lan",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum LanJoinMaterial {
|
||||
pub enum LanJoinMaterial {
|
||||
RequiresSuppliedToken,
|
||||
}
|
||||
|
||||
|
|
@ -83,11 +37,11 @@ pub(crate) struct LanMeshAdvertisement {
|
|||
pub(crate) node_count: usize,
|
||||
pub(crate) client_count: usize,
|
||||
pub(crate) max_clients: usize,
|
||||
pub(crate) token_fingerprint: Option<String>,
|
||||
pub token_fingerprint: Option<String>,
|
||||
pub(crate) details_path: Option<String>,
|
||||
pub(crate) proof_challenge: Option<String>,
|
||||
pub(crate) app_version: Option<String>,
|
||||
pub(crate) join_material: LanJoinMaterial,
|
||||
pub join_material: LanJoinMaterial,
|
||||
}
|
||||
|
||||
impl LanMeshAdvertisement {
|
||||
|
|
@ -234,29 +188,29 @@ impl LanMeshAdvertisement {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub(crate) struct LanDiscoveredMesh {
|
||||
pub(crate) mode: &'static str,
|
||||
pub(crate) scope: DiscoveryScope,
|
||||
pub(crate) source: &'static str,
|
||||
pub(crate) service_type: &'static str,
|
||||
pub(crate) instance_name: String,
|
||||
pub(crate) host: String,
|
||||
pub(crate) port: u16,
|
||||
pub(crate) addresses: Vec<String>,
|
||||
pub(crate) listing: nostr::MeshListing,
|
||||
pub struct LanDiscoveredMesh {
|
||||
pub mode: &'static str,
|
||||
pub scope: DiscoveryScope,
|
||||
pub source: &'static str,
|
||||
pub service_type: &'static str,
|
||||
pub instance_name: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub addresses: Vec<String>,
|
||||
pub listing: nostr::MeshListing,
|
||||
pub(crate) token_fingerprint: Option<String>,
|
||||
pub(crate) details_path: Option<String>,
|
||||
pub(crate) proof_challenge: Option<String>,
|
||||
pub(crate) join_material: LanJoinMaterial,
|
||||
pub(crate) joinable_with_supplied_token: bool,
|
||||
pub(crate) published_version: Option<String>,
|
||||
pub(crate) discovered_at: u64,
|
||||
pub joinable_with_supplied_token: bool,
|
||||
pub published_version: Option<String>,
|
||||
pub discovered_at: u64,
|
||||
#[serde(skip)]
|
||||
join_token: Option<String>,
|
||||
}
|
||||
|
||||
impl LanDiscoveredMesh {
|
||||
pub(crate) fn join_token(&self) -> Option<&str> {
|
||||
pub fn join_token(&self) -> Option<&str> {
|
||||
self.join_token.as_deref()
|
||||
}
|
||||
|
||||
|
|
@ -465,7 +419,7 @@ fn register_lan_service(
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn discover_lan(
|
||||
pub async fn discover_lan(
|
||||
filter: &nostr::MeshFilter,
|
||||
supplied_invite_token: Option<&str>,
|
||||
timeout: Duration,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use crate::api;
|
||||
use crate::cli::output::{OutputEvent, emit_event};
|
||||
use crate::inference::{election, pipeline};
|
||||
use crate::mesh;
|
||||
use crate::network::affinity;
|
||||
use crate::network::openai::auto_route;
|
||||
use crate::network::openai::transport as proxy;
|
||||
use crate::network::router;
|
||||
use mesh_llm_events::{OutputEvent, emit_event};
|
||||
use mesh_llm_node::serving::{UnloadOptions, UnloadTarget};
|
||||
use mesh_mixture_of_agents as moa;
|
||||
|
||||
|
|
|
|||
|
|
@ -505,6 +505,7 @@ pub struct RoutingCandidate<'a> {
|
|||
impl<'a> RoutingCandidate<'a> {
|
||||
/// Build a candidate without any throughput hint. Useful for
|
||||
/// pre-startup paths or test fixtures.
|
||||
#[cfg(test)]
|
||||
pub fn unscored(name: &'a str, caps: crate::models::ModelCapabilities) -> Self {
|
||||
Self {
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -296,7 +296,10 @@ mod tests {
|
|||
include_str!("../../tests/fixtures/skippy_full_surface_invalid.toml");
|
||||
|
||||
fn documented_matrix_key_paths() -> BTreeSet<String> {
|
||||
let matrix = include_str!("../../../../docs/skippy/CONFIGURATION.md");
|
||||
let matrix = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../docs/skippy/CONFIGURATION.md"
|
||||
));
|
||||
matrix
|
||||
.lines()
|
||||
.filter(|line| line.starts_with('|'))
|
||||
|
|
@ -1416,7 +1419,10 @@ mmproj = "multimodal.gguf"
|
|||
"omitted per-model request defaults should stay absent"
|
||||
);
|
||||
|
||||
let matrix = include_str!("../../../../docs/skippy/CONFIGURATION.md");
|
||||
let matrix = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../docs/skippy/CONFIGURATION.md"
|
||||
));
|
||||
let matrix_keys = documented_matrix_key_paths();
|
||||
assert!(
|
||||
matrix_keys.len() >= 100,
|
||||
|
|
@ -1437,9 +1443,10 @@ mmproj = "multimodal.gguf"
|
|||
assert!(matrix.contains(key), "missing matrix doc entry {key}");
|
||||
}
|
||||
|
||||
let docs_readme = include_str!("../../../../docs/README.md");
|
||||
let usage = include_str!("../../../../docs/USAGE.md");
|
||||
let cli = include_str!("../../../../docs/CLI.md");
|
||||
let docs_readme =
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/README.md"));
|
||||
let usage = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/USAGE.md"));
|
||||
let cli = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/CLI.md"));
|
||||
assert!(docs_readme.contains("[skippy/CONFIGURATION.md](skippy/CONFIGURATION.md)"));
|
||||
assert!(usage.contains("request payload values still win"));
|
||||
assert!(cli.contains("Request defaults only fill absent or null request fields"));
|
||||
|
|
|
|||
|
|
@ -590,7 +590,7 @@ impl PluginManager {
|
|||
summaries
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&self) {
|
||||
pub async fn shutdown(&self) {
|
||||
self.inner.shutting_down.store(true, Ordering::SeqCst);
|
||||
for plugin in self.inner.plugins.values() {
|
||||
plugin.shutdown().await;
|
||||
|
|
@ -1150,7 +1150,7 @@ impl PluginManager {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub async fn set_test_stream_handler<F>(&self, plugin_name: &str, handler: F)
|
||||
pub(crate) async fn set_test_stream_handler<F>(&self, plugin_name: &str, handler: F)
|
||||
where
|
||||
F: Fn(proto::OpenStreamRequest) -> TestStreamFuture + Send + Sync + 'static,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
use crate::cli::Cli;
|
||||
use crate::cli::output::{OutputEvent, emit_event};
|
||||
use crate::mesh;
|
||||
use crate::network::{discovery as mesh_discovery, nostr, router};
|
||||
use anyhow::{Context, Result};
|
||||
use crate::network::{discovery as mesh_discovery, nostr};
|
||||
use crate::runtime::RuntimeOptions;
|
||||
use mesh_llm_events::{OutputEvent, emit_event};
|
||||
use std::cmp::Reverse;
|
||||
|
||||
/// Health probe: try QUIC connect to the mesh's bootstrap node.
|
||||
|
|
@ -402,21 +401,21 @@ fn current_unix_secs() -> u64 {
|
|||
|
||||
/// Helper for StartNew path — configure CLI to start a new mesh.
|
||||
pub(super) fn start_new_mesh(
|
||||
cli: &mut Cli,
|
||||
options: &mut RuntimeOptions,
|
||||
models: &[String],
|
||||
my_vram_gb: f64,
|
||||
has_startup_models: bool,
|
||||
) {
|
||||
let primary = models.first().cloned().unwrap_or_default();
|
||||
if !has_startup_models && cli.model.is_empty() {
|
||||
cli.model.push(primary.clone().into());
|
||||
if !has_startup_models && options.model.is_empty() {
|
||||
options.model.push(primary.clone().into());
|
||||
}
|
||||
let detail = if has_startup_models {
|
||||
"using configured startup models".to_string()
|
||||
} else {
|
||||
format!("serving: {primary}")
|
||||
};
|
||||
let discovery = if cli.publish {
|
||||
let discovery = if options.publish {
|
||||
"publishing for discovery"
|
||||
} else {
|
||||
"mesh is private — add --publish to advertise it for discovery"
|
||||
|
|
@ -430,7 +429,7 @@ pub(super) fn start_new_mesh(
|
|||
});
|
||||
}
|
||||
|
||||
pub(crate) fn nostr_relays(cli_relays: &[String]) -> Vec<String> {
|
||||
pub fn nostr_relays(cli_relays: &[String]) -> Vec<String> {
|
||||
if cli_relays.is_empty() {
|
||||
nostr::DEFAULT_RELAYS
|
||||
.iter()
|
||||
|
|
@ -441,113 +440,6 @@ pub(crate) fn nostr_relays(cli_relays: &[String]) -> Vec<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Ensure mesh-llm is running on `port`, then return (available_models, chosen_model, spawned_child).
|
||||
///
|
||||
/// Launcher behavior: if nothing is listening yet, auto-start `mesh-llm client --auto`
|
||||
/// (client node — tunnels to mesh peers without publishing to Nostr).
|
||||
/// Returns the child process handle if we spawned one, so callers can clean up on exit.
|
||||
pub(crate) async fn check_mesh(
|
||||
client: &reqwest::Client,
|
||||
port: u16,
|
||||
model: &Option<String>,
|
||||
) -> Result<(Vec<String>, String, Option<std::process::Child>)> {
|
||||
let url = format!("http://127.0.0.1:{port}/v1/models");
|
||||
|
||||
let mut child: Option<std::process::Child> = None;
|
||||
if client.get(&url).send().await.is_err() {
|
||||
let _ = emit_event(OutputEvent::Info {
|
||||
message: format!("No mesh-llm on port {port} — starting background auto-join node"),
|
||||
context: None,
|
||||
});
|
||||
let exe = std::env::current_exe().unwrap_or_else(|_| "mesh-llm".into());
|
||||
child = Some(
|
||||
std::process::Command::new(&exe)
|
||||
.args(["client", "--auto", "--port", &port.to_string()])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.context("Failed to start mesh-llm node")?,
|
||||
);
|
||||
}
|
||||
|
||||
let mut models: Vec<String> = Vec::new();
|
||||
for i in 0..40 {
|
||||
if let Ok(resp) = client.get(&url).send().await
|
||||
&& let Ok(body) = resp.json::<serde_json::Value>().await
|
||||
{
|
||||
models = body["data"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|m| m["id"].as_str().map(String::from))
|
||||
.collect();
|
||||
if !models.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
if i % 5 == 4 {
|
||||
let _ = emit_event(OutputEvent::Info {
|
||||
message: format!("Waiting for mesh/models... ({:.0}s)", (i + 1) as f64 * 3.0),
|
||||
context: Some(format!("port={port}")),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if models.is_empty() {
|
||||
if let Some(mut c) = child {
|
||||
let _ = c.kill();
|
||||
}
|
||||
anyhow::bail!(
|
||||
"mesh-llm on port {port} has no models yet (or could not be reached).\n\
|
||||
Ensure at least one serving peer is available on the mesh."
|
||||
);
|
||||
}
|
||||
|
||||
let chosen = if let Some(m) = model {
|
||||
if !models.iter().any(|n| n == m) {
|
||||
if let Some(mut c) = child {
|
||||
let _ = c.kill();
|
||||
let _ = c.wait();
|
||||
}
|
||||
anyhow::bail!(
|
||||
"Model '{}' not available. Available: {}",
|
||||
m,
|
||||
models.join(", ")
|
||||
);
|
||||
}
|
||||
m.clone()
|
||||
} else {
|
||||
// Pre-startup path: no live routing metrics yet, so candidates
|
||||
// are scored as cold (uniform weight).
|
||||
let available: Vec<router::RoutingCandidate<'_>> = models
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let caps = crate::models::installed_model_capabilities(n);
|
||||
router::RoutingCandidate::unscored(n.as_str(), caps)
|
||||
})
|
||||
.collect();
|
||||
let agentic = router::Classification {
|
||||
category: router::Category::Code,
|
||||
complexity: router::Complexity::Deep,
|
||||
needs_tools: true,
|
||||
has_media_inputs: false,
|
||||
};
|
||||
router::pick_model_classified(&agentic, &available)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| models[0].clone())
|
||||
};
|
||||
let _ = emit_event(OutputEvent::Info {
|
||||
message: format!("Models: {}", models.join(", ")),
|
||||
context: Some(format!("port={port}")),
|
||||
});
|
||||
let _ = emit_event(OutputEvent::Info {
|
||||
message: format!("Using: {chosen}"),
|
||||
context: Some(format!("port={port}")),
|
||||
});
|
||||
Ok((models, chosen, child))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue