project-nomad/admin/app/utils/model_capabilities.ts
Chris Sherwood 26f40baf58 fix(ai): probe a model the list reports without vision before disabling images
Ollama 0.33.3 gives two answers for gemma3: /api/tags lists it as
['completion'], while /api/show for the same model reports
['completion', 'vision']. getModelCapabilities trusted the list outright
and cached it for the life of the process, so image upload stayed disabled
for gemma3, one of the most common small vision models, even though the
model downloader correctly shows it as text + image. Re-pulling does not
help: the local digest matches the registry.

A list that reports vision is still trusted without probing. A list that
omits it is no longer conclusive: the model is probed through /api/show
(then llama.cpp /props), and the list's answer stands only when the probes
have nothing better, which keeps hybrid routers without /api/show working.
The service's own copy of the short-circuit is removed so the rule lives in
the one tested function.

Verified on NOMAD3 (v1.35.0 + this patch): installed-models moves gemma3:4b
from unsupported to supported, every other model unchanged, and a chat with
an attached image is answered correctly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:25:28 -07:00

91 lines
3.1 KiB
TypeScript

import type { ModelVisionCapability } from '../../types/ollama.js'
export type ModelCapabilities = {
thinking: boolean
vision: ModelVisionCapability
}
export type ModelCapabilityProbes = {
ollamaShow: () => Promise<unknown>
llamaProps: () => Promise<unknown>
}
export type InstalledModelMetadata = {
name: string
size: number
capabilities?: string[]
}
export function capabilitiesFromOllamaShow(value: unknown): ModelCapabilities | null {
if (!value || typeof value !== 'object') return null
const capabilities = (value as { capabilities?: unknown }).capabilities
if (!Array.isArray(capabilities) || !capabilities.every((item) => typeof item === 'string')) {
return null
}
return {
thinking: capabilities.includes('thinking'),
vision: capabilities.includes('vision') ? 'supported' : 'unsupported',
}
}
export function visionCapabilityFromLlamaProps(value: unknown): ModelVisionCapability | null {
if (!value || typeof value !== 'object') return null
const modalities = (value as { modalities?: unknown }).modalities
if (modalities && typeof modalities === 'object' && !Array.isArray(modalities)) {
const vision = (modalities as { vision?: unknown }).vision
return typeof vision === 'boolean' ? (vision ? 'supported' : 'unsupported') : null
}
if (Array.isArray(modalities) && modalities.every((item) => typeof item === 'string')) {
return modalities.includes('image') || modalities.includes('vision')
? 'supported'
: 'unsupported'
}
return null
}
export async function resolveModelCapabilities(
advertisedMetadata: unknown,
probes: ModelCapabilityProbes
): Promise<ModelCapabilities | null> {
const advertised = capabilitiesFromOllamaShow(advertisedMetadata)
// A model list that reports vision is trusted outright. One that does not is
// not conclusive: Ollama's /api/tags lists gemma3 as ['completion'] while its
// /api/show for the same model reports ['completion', 'vision'], which left
// image upload disabled for one of the most common small vision models.
if (advertised?.vision === 'supported') return advertised
try {
const ollama = capabilitiesFromOllamaShow(await probes.ollamaShow())
if (ollama) return ollama
} catch {}
try {
const vision = visionCapabilityFromLlamaProps(await probes.llamaProps())
if (vision) return { thinking: advertised?.thinking ?? false, vision }
} catch {}
return advertised
}
export function installedModelsFromOpenAIResponse(value: unknown): InstalledModelMetadata[] {
if (!value || typeof value !== 'object') return []
const data = (value as { data?: unknown }).data
if (!Array.isArray(data)) return []
return data.flatMap((entry) => {
if (!entry || typeof entry !== 'object') return []
const { id, capabilities } = entry as { id?: unknown; capabilities?: unknown }
if (typeof id !== 'string') return []
const model: InstalledModelMetadata = { name: id, size: 0 }
if (
Array.isArray(capabilities) &&
capabilities.every((capability) => typeof capability === 'string')
) {
model.capabilities = capabilities
}
return [model]
})
}