Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .github/workflows/publish-performance-pack.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ jobs:
run: ./scripts/fetch-win-binaries.ps1
- name: Build Kev server runtime (model weights excluded)
shell: pwsh
run: ./scripts/build-kev-runtime.ps1
run: |
./scripts/build-kev-runtime.ps1
. ./scripts/config/windows-vc-runtime.ps1
Get-ChildItem resources/bin/kev-runtime -Recurse -File -Filter '*.exe' |
Select-Object -ExpandProperty DirectoryName -Unique |
ForEach-Object { Copy-VCRuntime $_ }
- name: Check staged Windows runtimes
shell: bash
run: |
Expand Down Expand Up @@ -160,6 +165,13 @@ jobs:
rm -rf "$BIN_ROOT/llama-cuda" "$BIN_ROOT/cuda-runtime"
cp -a "$RUNNER_TEMP/engine/llama-cuda" "$RUNNER_TEMP/engine/cuda-runtime" "$BIN_ROOT/"
cat "$RUNNER_TEMP/engine/engine.json"
if [ "$PLATFORM" = win32 ]; then
# llama-cuda was staged on Linux; reuse the VC redist from the Windows job.
for dll in MSVCP140.dll MSVCP140_1.dll MSVCP140_2.dll MSVCP140_ATOMIC_WAIT.dll \
MSVCP140_CODECVT_IDS.dll VCRUNTIME140.dll VCRUNTIME140_1.dll CONCRT140.dll VCOMP140.dll; do
cp "$BIN_ROOT/whisper/$dll" "$BIN_ROOT/llama-cuda/$dll"
done
fi
- name: Check the Linux llama engines' libraries resolve
if: inputs.platform == 'linux'
run: |
Expand All @@ -183,7 +195,8 @@ jobs:
run: |
python3 -m pip install --no-cache-dir --no-deps --target "$RUNNER_TEMP/onnx-nvidia" \
'nvidia-cuda-runtime==13.2.86' 'nvidia-cublas==13.2.2.2' \
'nvidia-curand==10.4.2.66' 'nvidia-cudnn-cu13==9.23.1.3'
'nvidia-curand==10.4.2.66' 'nvidia-cudnn-cu13==9.23.1.3' \
'nvidia-cufft==12.2.0.57' 'nvidia-cuda-nvrtc==13.2.86'
mkdir -p "$BIN_ROOT/onnx-cuda"
for folder in cu13 cudnn; do
find "$RUNNER_TEMP/onnx-nvidia/nvidia/$folder/lib" -maxdepth 1 -type f -name '*.so.*' \
Expand Down
72 changes: 56 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ jobs:
build-mac:
needs: version
runs-on: macos-15 # Standard arm64 runner; matches the pinned native payload.
env:
MACOSX_DEPLOYMENT_TARGET: '13.0'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
Expand All @@ -139,13 +141,12 @@ jobs:
'https://github.com/off-grid-ai/OGAD/releases/download/native-deps-2026-09/ogad-mac-native-2026-09-arm64-v1.tar.gz'
echo 'ef1880daac727595bf825a254b1ebbc2a0c15c0cecd8da422c499b1568db0ad0 '"$RUNNER_TEMP/mac-native.tar.gz" | shasum -a 256 -c -
tar -xzf "$RUNNER_TEMP/mac-native.tar.gz" -C resources/bin
- name: Build macOS image engine for macOS 13
run: bash scripts/build-sd-mac.sh
- name: Verify cached macOS engines
run: |
for variant in llama llama-prism; do
test -x "resources/bin/$variant/llama-server"
minos=$(otool -l "resources/bin/$variant/llama-server" | awk '/LC_BUILD_VERSION/{f=1} f&&/minos/{print $2; exit}')
test -n "$minos"
case "$minos" in 1[0-3].*|[1-9].*) ;; *) echo "Unsupported $variant minos $minos" >&2; exit 1 ;; esac
DYLD_LIBRARY_PATH="resources/bin/$variant" "resources/bin/$variant/llama-server" --help >/dev/null
done
test -x resources/bin/whisper/whisper-cli
Expand Down Expand Up @@ -283,6 +284,7 @@ jobs:
token: ${{ secrets.CI_CROSS_REPO_TOKEN }}
path: _executorch_speech
lfs: true # Mac releases bundle the default English voices for offline first use.
submodules: recursive
persist-credentials: false
- name: Put the speech runtime beside this checkout
run: |
Expand All @@ -292,6 +294,17 @@ jobs:
fi
rm -rf ../executorch-speech
mv _executorch_speech ../executorch-speech
- name: Build macOS speech runtime for macOS 13
run: |
npm --prefix ../executorch-speech ci
# Use the bundled Python and its existing torchgen, YAML, and Jinja dependencies.
speech_python="$GITHUB_WORKSPACE/resources/bin/kev-runtime/python/bin/python3"
"$speech_python" -c 'import torchgen, yaml, jinja2'
cmake -S ../executorch-speech/native -B ../executorch-speech/native/build \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_DEPLOYMENT_TARGET=13.0 \
-DFLATCC_ALLOW_WERROR=OFF -DPYTHON_EXECUTABLE="$speech_python"
cmake --build ../executorch-speech/native/build --config Release --parallel 4 \
--target executorch-speech
- name: Let npm read the private UI package (core and Pro)
# @offgrid/operator-ui is a private Wednesday package that core and Pro both render with
# (skin, primitives, motion). It is listed as optional, so npm would skip it silently
Expand Down Expand Up @@ -382,14 +395,15 @@ jobs:
printf '%s' "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
export APPLE_API_KEY="$RUNNER_TEMP/AuthKey.p8"
npm run prepare:speech-defaults
node scripts/check-macos-minos.mjs 13.0
# Build every artifact with publication disabled. The artifact hook verifies
# both the updater ZIP and DMG before this command can complete.
npx electron-builder --mac \
-c.dmg.artifactName='OffGrid-${version}.${ext}' \
-c.dmg.artifactName='OffGrid-${version}-${arch}.${ext}' \
--publish never
# electron-builder notarizes the app before it creates the DMG. Submit the
# completed, signed DMG too, then staple its ticket for offline Gatekeeper.
DMG="dist/OffGrid-$VERSION.dmg"
DMG="dist/OffGrid-$VERSION-arm64.dmg"
xcrun notarytool submit "$DMG" \
--key "$APPLE_API_KEY" \
--key-id "$APPLE_API_KEY_ID" \
Expand Down Expand Up @@ -485,7 +499,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
run: |
cp "dist/OffGrid-$VERSION.dmg" dist/OffGrid-latest.dmg
cp "dist/OffGrid-$VERSION-arm64.dmg" dist/OffGrid-latest.dmg
gh release upload "v$VERSION" dist/OffGrid-latest.dmg --clobber
# Constant nightly download link. /releases/latest EXCLUDES pre-releases, so it
# can never point at a beta build (that is exactly what keeps the STABLE link
Expand All @@ -499,7 +513,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
run: |
cp "dist/OffGrid-$VERSION.dmg" dist/OffGrid-nightly.dmg
cp "dist/OffGrid-$VERSION-arm64.dmg" dist/OffGrid-nightly.dmg
# Ensure the rolling 'nightly' pre-release exists (kept separate from the
# versioned -beta.N releases electron-builder makes for the updater feed),
# then clobber its dmg so the link always serves the freshest nightly.
Expand Down Expand Up @@ -678,6 +692,14 @@ jobs:
fi
- name: Install dependencies
run: npm ci
- name: Install Vulkan SDK for Whisper
uses: humbletim/install-vulkan-sdk@v1.2
with:
version: '1.4.321.1'
cache: true
- name: Build Windows Vulkan Whisper
shell: pwsh
run: ./scripts/build-whisper-vulkan-win.ps1
- name: Fetch Windows native binaries (llama/whisper/sd/ffmpeg)
shell: pwsh
env:
Expand All @@ -694,7 +716,7 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
MICROSOFT_CLIENT_ID: ${{ vars.MICROSOFT_CLIENT_ID }}
run: node scripts/stage-native.mjs && npx electron-vite build && node scripts/check-windows-onnx-preload.mjs
run: node scripts/stage-native.mjs && npx electron-vite build
- name: Package Windows web installer, payload, and update metadata
shell: bash
env:
Expand All @@ -708,10 +730,10 @@ jobs:
shell: bash
run: |
test -s dist/win-unpacked/resources/performance-packs.json
for binary in llama/llama-server.exe llama-cpu/llama-server.exe llama-prism/llama-server.exe llama-prism-cpu/llama-server.exe whisper-cpu/whisper-cli.exe sd/sd-cli.exe sd-cpu/sd-cli.exe ffmpeg.exe; do
for binary in llama/llama-server.exe llama-cpu/llama-server.exe llama-prism/llama-server.exe llama-prism-cpu/llama-server.exe whisper-cpu/whisper-cli.exe whisper-vulkan/whisper-cli.exe whisper-vulkan/whisper-server.exe sd/sd-cli.exe sd-cpu/sd-cli.exe ffmpeg.exe; do
test -s "dist/win-unpacked/resources/bin/$binary"
done
for folder in llama-cuda llama-prism-cuda cuda-runtime sd-cuda whisper kev-runtime; do
for folder in llama-cuda llama-prism-cuda cuda-runtime sd-cuda whisper kev-runtime onnx-cuda; do
test ! -e "dist/win-unpacked/resources/bin/$folder"
done
- name: Report Windows native payload sizes
Expand Down Expand Up @@ -925,6 +947,14 @@ jobs:
run: npm ci
- name: Install Linux GPU runtime dependencies
run: sudo apt-get update -qq && sudo apt-get install -y libgomp1 libvulkan1 libvulkan-dev glslc spirv-headers unzip
- name: Stage Vulkan loaders beside cached Linux engines
run: |
source="$(ldconfig -p | awk '$1 == "libvulkan.so.1" && /x86-64/ && !found { found=$NF } END { print found }')"
test -n "$source"
for folder in llama llama-prism whisper; do
install -m 755 "$(readlink -f "$source")" "build/linux-bin/$folder/libvulkan.so.1"
test -s "build/linux-bin/$folder/libvulkan.so.1"
done
- name: Typecheck Linux main process
env:
OFFGRID_FORCE_CORE: '0'
Expand Down Expand Up @@ -953,8 +983,21 @@ jobs:
- name: Verify Linux package inputs
run: |
test -s dist/linux-unpacked/resources/performance-packs.json
provider="$(find dist/linux-unpacked/resources/app.asar.unpacked/node_modules \
-path '*/onnxruntime-node/*' -name libonnxruntime_providers_cuda.so -type f -print -quit)"
test -n "$provider"
test -s "$provider"
for folder in llama llama-prism whisper; do
test -s "dist/linux-unpacked/resources/bin/$folder/libvulkan.so.1"
done
while IFS= read -r -d '' binary; do
if file "$binary" | grep -q 'ELF.*executable'; then
glibc="$(objdump -T "$binary" | grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1 || true)"
printf '[ci] %s highest GLIBC: %s\n' "$binary" "${glibc:-none (static binary)}"
fi
done < <(find dist/linux-unpacked/resources/bin -type f -print0)
cmp resources/bin/kev-local-server.py dist/linux-unpacked/resources/bin/kev-local-server.py
for folder in llama-cuda llama-prism-cuda cuda-runtime sd-cuda whisper-cuda kev-runtime; do
for folder in llama-cuda llama-prism-cuda cuda-runtime sd-cuda whisper-cuda kev-runtime onnx-cuda; do
test ! -e "dist/linux-unpacked/resources/bin/$folder"
done
for variant in llama llama-cpu llama-prism llama-prism-cpu; do
Expand Down Expand Up @@ -1007,9 +1050,6 @@ jobs:
const root = path.resolve("dist/linux-unpacked/resources/app.asar/node_modules")
const Database = require(path.join(root, "better-sqlite3-multiple-ciphers"))
new Database(":memory:").close()
if (typeof require(path.join(root, "@lancedb/lancedb")).connect !== "function") {
throw new Error("LanceDB native package did not load")
}
if (typeof require(path.join(root, "sharp")) !== "function") {
throw new Error("Sharp native package did not load")
}
Expand All @@ -1032,7 +1072,7 @@ jobs:
test -n "$bundled"
cmp "dist/linux-unpacked/resources/bin/$variant/llama-server" "$bundled"
done
for helper in whisper/whisper-cli whisper-cpu/whisper-cli whisper/LICENSE ffmpeg licenses/ffmpeg.txt sd/sd-cli sd/sd-server sd/libggml-vulkan.so sd/libgomp.so.1 sd/libvulkan.so.1 executorch-speech licenses/libgomp1.txt licenses/libvulkan1.txt; do
for helper in llama/libvulkan.so.1 llama-prism/libvulkan.so.1 whisper/libvulkan.so.1 whisper/whisper-cli whisper-cpu/whisper-cli whisper/LICENSE ffmpeg licenses/ffmpeg.txt sd/sd-cli sd/sd-server sd/libggml-vulkan.so sd/libgomp.so.1 sd/libvulkan.so.1 executorch-speech licenses/libgomp1.txt licenses/libvulkan1.txt; do
bundled="$(find "$extraction/squashfs-root" -path "*/resources/bin/$helper" -type f -print -quit)"
test -n "$bundled"
cmp "dist/linux-unpacked/resources/bin/$helper" "$bundled"
Expand All @@ -1049,7 +1089,7 @@ jobs:
test -n "$bundled"
cmp "dist/linux-unpacked/resources/bin/$variant/llama-server" "$bundled"
done
for helper in whisper/whisper-cli whisper-cpu/whisper-cli whisper/LICENSE ffmpeg licenses/ffmpeg.txt sd/sd-cli sd/sd-server sd/libggml-vulkan.so sd/libgomp.so.1 sd/libvulkan.so.1 executorch-speech licenses/libgomp1.txt licenses/libvulkan1.txt; do
for helper in llama/libvulkan.so.1 llama-prism/libvulkan.so.1 whisper/libvulkan.so.1 whisper/whisper-cli whisper-cpu/whisper-cli whisper/LICENSE ffmpeg licenses/ffmpeg.txt sd/sd-cli sd/sd-server sd/libggml-vulkan.so sd/libgomp.so.1 sd/libvulkan.so.1 executorch-speech licenses/libgomp1.txt licenses/libvulkan1.txt; do
bundled="$(find "$extraction/deb" -path "*/resources/bin/$helper" -type f -print -quit)"
test -n "$bundled"
cmp "dist/linux-unpacked/resources/bin/$helper" "$bundled"
Expand Down
6 changes: 6 additions & 0 deletions docs/RELEASE_DESKTOP.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,9 @@ quickly. No EV needed.

- [ ] Windows: bundled llama-server doesn't start — fix in progress separately.
- [ ] Azure Trusted Signing validated + `azureSignOptions` wired (§4) — resumes with Windows.

### Linux AppImage

The AppImage and deb packages are x64 only. On Ubuntu 24.04, install
`libfuse2t64` before you start the AppImage. Headless servers and some sandboxes
need the `--no-sandbox` option.
4 changes: 3 additions & 1 deletion e2e/app068-project-knowledge-isolation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,11 @@ async function createProject(project: string, document: string, content: string)
fs.writeFileSync(filePath, content)
await chooseFile(filePath)
await page.getByRole('button', { name: 'Add files', exact: true }).click()
await expect(page.getByText(`${document}: indexed`, { exact: true })).toBeVisible({
// Indexed: the row's progress bar is gone and it shows the ready line ("text · 62 B").
await expect(page.getByRole('progressbar', { name: `Indexing ${document}` })).toBeHidden({
timeout: 60_000
})
await expect(page.getByText(/^text\s+·\s+\d+ B$/).first()).toBeVisible({ timeout: 60_000 })
await expect(page.getByText(document, { exact: true })).toBeVisible()
}

Expand Down
124 changes: 124 additions & 0 deletions e2e/fixtures/gpu-fault-llama-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env node

/* eslint-disable @typescript-eslint/explicit-function-return-type -- executable JavaScript fixture */

// The GPU fault journey's only fake: the native llama-server boundary. Everything that decides what
// happens after a fault (the HTTP client's classifier, the chat fallback policy, the same-model
// restart, the backend ladder, the CPU-only launch flags, the runtime status and Settings) is
// production code. Like a real engine, this one reports where it put the model in its load log,
// and like a GPU that has lost its context it answers "500 Compute error" for as many questions as
// faults.json beside it still lists. A fault is spent when it is answered, across restarts.

import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const args = process.argv.slice(2)
const portFlag = Math.max(args.indexOf('--port'), args.indexOf('-p'))
const port = portFlag >= 0 ? Number(args[portFlag + 1]) : 8439
const deviceFlag = args.indexOf('--device')
const cpuOnly = deviceFlag >= 0 && args[deviceFlag + 1] === 'none'
const here = path.dirname(fileURLToPath(import.meta.url))
const FAULTS = path.join(here, 'faults.json')
const LOG = path.join(here, 'launches.jsonl')

fs.appendFileSync(LOG, `${JSON.stringify({ args })}\n`)

// The load log the app reads to name the backend and the layers on the GPU.
if (cpuOnly) {
process.stderr.write('load_tensors: CPU model buffer size = 120.00 MiB\n')
} else {
process.stderr.write('load_tensors: offloaded 28/28 layers to GPU\n')
process.stderr.write('load_tensors: Metal model buffer size = 120.00 MiB\n')
}

function takeFault() {
try {
const plan = JSON.parse(fs.readFileSync(FAULTS, 'utf8'))
if (!(plan.remaining > 0)) return false
fs.writeFileSync(FAULTS, JSON.stringify({ remaining: plan.remaining - 1 }))
return true
} catch {
return false
}
}

function lastUser(messages) {
const user = [...(messages ?? [])].reverse().find((message) => message.role === 'user')
const content = user?.content
return typeof content === 'string'
? content
: Array.isArray(content)
? content.map((part) => part?.text ?? '').join('')
: ''
}

const delta = (payload) => `data: ${JSON.stringify({ choices: [{ delta: payload }] })}\n\n`

const server = http.createServer((request, response) => {
if (request.method === 'GET' && request.url === '/health') {
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ status: 'ok' }))
return
}
if (request.method === 'GET' && request.url === '/v1/models') {
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ data: [{ id: 'gpu-fault-model' }] }))
return
}
if (request.method !== 'POST' || !String(request.url).includes('/chat/completions')) {
response.writeHead(404)
response.end()
return
}
let body = ''
request.on('data', (chunk) => {
body += chunk
})
request.on('end', () => {
let payload = {}
try {
payload = JSON.parse(body)
} catch {
/* answer the default */
}
const question = lastUser(payload.messages)
// Only the journey's own questions fault; follow-up suggestions and titles answer normally.
const faulted = question.startsWith('GPU check') && takeFault()
fs.appendFileSync(
LOG,
`${JSON.stringify({ question: question.slice(0, 80), stream: payload.stream !== false, faulted, cpuOnly })}\n`
)
if (faulted) {
response.writeHead(500, { 'Content-Type': 'application/json' })
response.end(
JSON.stringify({ error: { code: 500, message: 'Compute error', type: 'server_error' } })
)
return
}
const text = question.startsWith('GPU check')
? `Answered on the ${cpuOnly ? 'CPU' : 'GPU'}.`
: 'Okay.'
if (payload.stream === false) {
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(
JSON.stringify({
choices: [
{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content: text } }
]
})
)
return
}
response.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' })
response.write(delta({ content: text }))
response.write(
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\n`
)
response.write('data: [DONE]\n\n')
response.end()
})
})

server.listen(port, '127.0.0.1')
Loading
Loading