project-nomad/admin/app/validators/settings.ts
jakeaturner ac437f8429 fix(gpu): detect Ollama CPU fallback on AMD and offer the right fix
GpuPassthroughRemediationProvider returned early unless the NVIDIA
runtime was registered with Docker. AMD doesn't register one, so the
log-based CPU-fallback detection added in v1.35.0-rc.1 never ran on
AMD boxes. On a Phoenix1 iGPU running ollama:rocm with /dev/kfd and
/dev/dri passed through, Ollama logged library=cpu while gpuHealth
reported status "ok".

The gpuHealth side had its own bug. hasNvidiaRuntime and
hasRocmRuntime were set only inside the lspci probe branch. When lspci
returned a plausible controller, the else branch reported "ok" without
probing and left both flags false. A blind reinstall would not have
helped either: gfx1103 needs HSA_OVERRIDE_GFX_VERSION=11.0.0, and
without the gfx marker or ai.amdHsaOverride a reinstall rebuilds the
same CPU-bound container.

- Add resolveExpectedGpuVendor(). It reads the nomad_ollama container
  config first (nvidia DeviceRequest, ROCm image tag, /dev/kfd), then
  the nvidia runtime, then the AMD GPU type when no container exists.
  /dev/dri alone is not treated as AMD. A CPU container on an AMD box
  is trusted as deliberate (#1232 fallback, acceleration disabled).
- Gate the provider on the expected vendor. NVIDIA auto-reinstall is
  unchanged but now skips when the container requests a GPU with no
  runtime registered. AMD is detect-only: the provider logs whether a
  reinstall would change the container and never reinstalls itself.
- In gpuHealth, set the runtime flags up front and let Ollama's
  startup log decide health whenever a GPU is expected, whatever lspci
  returned. A CPU-only log now reports passthrough_failed and skips
  the nvidia-smi exec. lspci stays the display source when clean.
- Parse the first GPU "inference compute" line instead of the last
  line, which could be the CPU fallback device, and recognize Vulkan.
  Move the startup-log reader into ollama_compute so both callers
  share it.
- Report amdReinstallWouldChange, amdGfxTarget, currentHsaOverride and
  suggestedHsaOverride on AMD passthrough_failed. The gfx target is
  parsed from Ollama's "amdgpu is not supported" line (best-effort).
- Add GpuPassthroughAlert, shared by Settings > System and Settings >
  Models. When a reinstall would rebuild the same AMD container, it
  offers "Fix: Set GFX Override" instead of a reinstall. The dialog
  saves ai.amdHsaOverride and then reinstalls.
- Expose ai.amdHsaOverride through the settings API. The value lands
  in the container env, so only x.y.z, "none" or empty is accepted.
- Silence the HSA override resolver's logging on the gpuHealth poll.

The #1325 case where no GPU runtime is expected and lspci reports a
controller still returns "ok" unprobed.

Fixes #1344
Refs #1325, #1232
2026-09-29 15:54:41 -07:00

137 lines
6.2 KiB
TypeScript

import vine from "@vinejs/vine";
import { SETTINGS_KEYS } from "../../constants/kv_store.js";
import type { KVStoreKey } from "../../types/kv_store.js";
import { CONTEXT_LADDER } from "../utils/context_window.js";
import { RESPONSE_STYLE_PRESETS } from "../../constants/ollama.js";
export const getSettingSchema = vine.compile(vine.object({
key: vine.enum(SETTINGS_KEYS),
}))
export const updateSettingSchema = vine.compile(vine.object({
key: vine.enum(SETTINGS_KEYS),
value: vine.any().optional(),
}))
const HHMM_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/
/**
* Validate the *value* for keys that have format constraints beyond the generic
* enum/any check (the generic validator only constrains the key). Returns an
* error message string when invalid, or null when the value is acceptable.
*/
export function validateSettingValue(key: KVStoreKey, value: unknown): string | null {
switch (key) {
case 'autoUpdate.windowStart':
case 'autoUpdate.windowEnd':
case 'contentAutoUpdate.windowStart':
case 'contentAutoUpdate.windowEnd':
if (typeof value !== 'string' || !HHMM_PATTERN.test(value)) {
return 'Time window values must be in 24-hour HH:MM format (e.g. "20:00").'
}
return null
case 'autoUpdate.cooloffHours':
case 'contentAutoUpdate.cooloffHours': {
const num = Number(value)
if (!Number.isInteger(num) || num < 0 || num > 8760) {
return 'Cool-off must be a whole number of hours between 0 and 8760.'
}
return null
}
case 'system.internetStatusTestUrl': {
// Empty clears the setting (reverts to env var / built-in defaults).
if (value === '' || value === undefined || value === null) {
return null
}
if (typeof value !== 'string') {
return 'Test URL must be a string.'
}
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return 'Test URL must use http or https.'
}
} catch {
return 'Test URL must be a valid URL (e.g. "https://example.com").'
}
return null
}
case 'contentAutoUpdate.maxBytesPerWindow': {
// Per-window download budget in bytes. 0 = unlimited.
const num = Number(value)
if (!Number.isInteger(num) || num < 0) {
return 'The per-window data cap must be a whole number of bytes (0 = unlimited).'
}
return null
}
case 'ai.contextWindow': {
// "auto" (or empty) hands sizing to ContextWindowService. An explicit
// value is a *cap*, so it only ever lowers the resolved window — but it
// still has to be a real ladder rung, since a value the backend can't
// honour would silently fall back to the default and confuse the user.
if (value === '' || value === 'auto' || value === undefined || value === null) {
return null
}
const num = Number(value)
if (!CONTEXT_LADDER.includes(num as (typeof CONTEXT_LADDER)[number])) {
return `Context window must be "auto" or one of: ${CONTEXT_LADDER.join(', ')}.`
}
return null
}
case 'rag.minRelevance': {
// Empty/'auto' clears the setting and reverts to RAG_MIN_FINAL_SCORE.
// An explicit 0 is a real choice — it turns the floor off — so it is
// deliberately not treated as "unset".
// Trimmed first so the accepted set matches parseMinRelevance's:
// Number(' ') is 0, which would otherwise validate as "floor off"
// for a value the parser reads as unset.
const raw = typeof value === 'string' ? value.trim() : value
if (raw === '' || raw === 'auto' || raw === undefined || raw === null) {
return null
}
const num = Number(raw)
if (!Number.isFinite(num) || num < 0 || num > 1) {
return 'Relevance threshold must be a number between 0 and 1, or empty to use the recommended default.'
}
return null
}
case 'ai.responseStyle': {
// Empty/unset means 'auto'. Anything else has to be a style we know,
// since an unrecognised value silently resolves back to the default
// and would leave the select showing a choice nothing honours.
if (value === '' || value === undefined || value === null) {
return null
}
const styles = RESPONSE_STYLE_PRESETS.map((preset) => preset.value)
if (typeof value !== 'string' || !styles.includes(value as (typeof styles)[number])) {
return `Response style must be one of: ${styles.join(', ')}.`
}
return null
}
case 'ai.keepAlive': {
// Ollama duration format: "10m", "1h", "30s", "-1" (forever), "0" (evict).
if (value === '' || value === undefined || value === null) {
return null
}
if (typeof value !== 'string' || !/^(-?\d+|\d+(\.\d+)?(ms|s|m|h))$/.test(value)) {
return 'Keep-alive must be a duration like "10m", "1h", or "-1" to keep the model loaded indefinitely.'
}
return null
}
case 'ai.amdHsaOverride': {
// Lands in the Ollama container env as HSA_OVERRIDE_GFX_VERSION, so only
// the forms DockerService._resolveAmdHsaOverride honours are accepted.
// Empty clears it and 'none' disables the gfx-marker default.
const raw = typeof value === 'string' ? value.trim().toLowerCase() : value
if (raw === '' || raw === undefined || raw === null || raw === 'none') {
return null
}
if (typeof raw !== 'string' || !/^\d{1,2}\.\d{1,2}\.\d{1,2}$/.test(raw)) {
return 'GFX override must be a version like "11.0.0", "none", or empty to use the detected default.'
}
return null
}
default:
return null
}
}