chore: validate gguf file base metadata architecture

This commit is contained in:
Faisal Amir 2025-09-08 20:16:20 +07:00
parent 836990b7d9
commit be851ebcf1
4 changed files with 420 additions and 40 deletions

View File

@ -1999,4 +1999,47 @@ export default class llamacpp_extension extends AIEngine {
throw new Error(String(e))
}
}
/**
* Validate GGUF file and check for unsupported architectures like CLIP
*/
async validateGgufFile(filePath: string): Promise<{
isValid: boolean
error?: string
metadata?: GgufMetadata
}> {
try {
logger.info(`Validating GGUF file: ${filePath}`)
const metadata = await readGgufMetadata(filePath)
// Log full metadata for debugging
logger.info('Full GGUF metadata:', JSON.stringify(metadata, null, 2))
// Check if architecture is 'clip' which is not supported for text generation
const architecture = metadata.metadata?.['general.architecture']
logger.info(`Model architecture: ${architecture}`)
if (architecture === 'clip') {
const errorMessage = 'This model has CLIP architecture and cannot be imported as a text generation model. CLIP models are designed for vision tasks and require different handling.'
logger.error('CLIP architecture detected:', architecture)
return {
isValid: false,
error: errorMessage,
metadata
}
}
logger.info('Model validation passed. Architecture:', architecture)
return {
isValid: true,
metadata
}
} catch (error) {
logger.error('Failed to validate GGUF file:', error)
return {
isValid: false,
error: `Failed to read model metadata: ${error instanceof Error ? error.message : 'Unknown error'}`
}
}
}
}

View File

@ -17,6 +17,7 @@ import {
IconLoader2,
IconEye,
IconCheck,
IconAlertTriangle,
} from '@tabler/icons-react'
type ImportVisionModelDialogProps = {
@ -37,6 +38,175 @@ export const ImportVisionModelDialog = ({
const [modelFile, setModelFile] = useState<string | null>(null)
const [mmProjFile, setMmProjFile] = useState<string | null>(null)
const [modelName, setModelName] = useState('')
const [validationError, setValidationError] = useState<string | null>(null)
const [isValidating, setIsValidating] = useState(false)
const [mmprojValidationError, setMmprojValidationError] = useState<
string | null
>(null)
const [isValidatingMmproj, setIsValidatingMmproj] = useState(false)
const validateGgufFile = async (
filePath: string,
fileType: 'model' | 'mmproj'
): Promise<void> => {
if (fileType === 'model') {
setIsValidating(true)
setValidationError(null)
} else {
setIsValidatingMmproj(true)
setMmprojValidationError(null)
}
try {
console.log(`Reading GGUF metadata for ${fileType}:`, filePath)
// Try to use the validateGgufFile method if available
if (typeof serviceHub.models().validateGgufFile === 'function') {
const result = await serviceHub.models().validateGgufFile(filePath)
if (result.metadata) {
// Log full metadata for debugging
console.log(
`Full GGUF metadata for ${fileType}:`,
JSON.stringify(result.metadata, null, 2)
)
// Check architecture from metadata
const architecture =
result.metadata.metadata?.['general.architecture']
console.log(`${fileType} architecture:`, architecture)
// Validate based on file type
if (fileType === 'model') {
// Model files should NOT be clip
if (architecture === 'clip') {
const errorMessage =
'This model has CLIP architecture and cannot be imported as a text generation model. CLIP models are designed for vision tasks and require different handling.'
setValidationError(errorMessage)
console.error(
'CLIP architecture detected in model file:',
architecture
)
} else {
console.log(
'Model validation passed. Architecture:',
architecture
)
}
} else {
// MMProj files MUST be clip
if (architecture !== 'clip') {
const errorMessage = `This MMProj file has "${architecture}" architecture but should have "clip" architecture. MMProj files must be CLIP models for vision processing.`
setMmprojValidationError(errorMessage)
console.error(
'Non-CLIP architecture detected in mmproj file:',
architecture
)
} else {
console.log(
'MMProj validation passed. Architecture:',
architecture
)
}
}
}
if (!result.isValid && fileType === 'model') {
setValidationError(result.error || 'Model validation failed')
console.error('Model validation failed:', result.error)
} else if (!result.isValid && fileType === 'mmproj') {
setMmprojValidationError(result.error || 'MMProj validation failed')
console.error('MMProj validation failed:', result.error)
}
} else {
// Fallback: Try to call the Tauri plugin directly if available
try {
// Import the readGgufMetadata function directly from Tauri
const { invoke } = await import('@tauri-apps/api/core')
const metadata = await invoke('plugin:llamacpp|read_gguf_metadata', {
path: filePath,
})
console.log(
`Full GGUF metadata for ${fileType}:`,
JSON.stringify(metadata, null, 2)
)
// Check if architecture matches expected type
const architecture = (
metadata as { metadata?: Record<string, string> }
).metadata?.['general.architecture']
console.log(`${fileType} architecture:`, architecture)
if (fileType === 'model') {
// Model files should NOT be clip
if (architecture === 'clip') {
const errorMessage =
'This model has CLIP architecture and cannot be imported as a text generation model. CLIP models are designed for vision tasks and require different handling.'
setValidationError(errorMessage)
console.error(
'CLIP architecture detected in model file:',
architecture
)
} else {
console.log(
'Model validation passed. Architecture:',
architecture
)
}
} else {
// MMProj files MUST be clip
if (architecture !== 'clip') {
const errorMessage = `This MMProj file has "${architecture}" architecture but should have "clip" architecture. MMProj files must be CLIP models for vision processing.`
setMmprojValidationError(errorMessage)
console.error(
'Non-CLIP architecture detected in mmproj file:',
architecture
)
} else {
console.log(
'MMProj validation passed. Architecture:',
architecture
)
}
}
} catch (tauriError) {
console.warn(
`Tauri validation fallback failed for ${fileType}:`,
tauriError
)
// Final fallback: just warn and allow
console.log(
`${fileType} validation skipped - validation service not available`
)
}
}
} catch (error) {
console.error(`Failed to validate ${fileType} file:`, error)
const errorMessage = `Failed to read ${fileType} metadata: ${error instanceof Error ? error.message : 'Unknown error'}`
if (fileType === 'model') {
setValidationError(errorMessage)
} else {
setMmprojValidationError(errorMessage)
}
} finally {
if (fileType === 'model') {
setIsValidating(false)
} else {
setIsValidatingMmproj(false)
}
}
}
const validateModelFile = async (filePath: string): Promise<void> => {
await validateGgufFile(filePath, 'model')
}
const validateMmprojFile = async (filePath: string): Promise<void> => {
await validateGgufFile(filePath, 'mmproj')
}
const handleFileSelect = async (type: 'model' | 'mmproj') => {
const selectedFile = await serviceHub.dialog().open({
@ -55,8 +225,13 @@ export const ImportVisionModelDialog = ({
.replace(/\.(gguf|GGUF)$/, '')
.replace(/[^a-zA-Z0-9/_.-]/g, '') // Remove any characters not allowed in model IDs
setModelName(sanitizedName)
// Validate the selected model file
await validateModelFile(selectedFile)
} else {
setMmProjFile(selectedFile)
// Validate the selected mmproj file
await validateMmprojFile(selectedFile)
}
}
}
@ -131,6 +306,10 @@ export const ImportVisionModelDialog = ({
setMmProjFile(null)
setModelName('')
setIsVisionModel(false)
setValidationError(null)
setIsValidating(false)
setMmprojValidationError(null)
setIsValidatingMmproj(false)
}
const handleOpenChange = (newOpen: boolean) => {
@ -209,24 +388,73 @@ export const ImportVisionModelDialog = ({
</div>
{modelFile ? (
<div className="bg-accent/10 border border-accent/20 rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconCheck size={16} className="text-accent" />
<span className="text-sm font-medium text-main-view-fg">
{modelFile.split(/[\\/]/).pop()}
</span>
<div className="space-y-2">
<div className="bg-accent/10 border border-accent/20 rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isValidating ? (
<IconLoader2
size={16}
className="text-accent animate-spin"
/>
) : validationError ? (
<IconAlertTriangle
size={16}
className="text-destructive"
/>
) : (
<IconCheck size={16} className="text-accent" />
)}
<span className="text-sm font-medium text-main-view-fg">
{modelFile.split(/[\\/]/).pop()}
</span>
</div>
<Button
variant="link"
size="sm"
onClick={() => handleFileSelect('model')}
disabled={importing || isValidating}
className="text-accent hover:text-accent/80"
>
Change
</Button>
</div>
<Button
variant="link"
size="sm"
onClick={() => handleFileSelect('model')}
disabled={importing}
className="text-accent hover:text-accent/80"
>
Change
</Button>
</div>
{/* Validation Error Display */}
{validationError && (
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-3">
<div className="flex items-start gap-2">
<IconAlertTriangle
size={16}
className="text-destructive mt-0.5 flex-shrink-0"
/>
<div>
<p className="text-sm font-medium text-destructive-fg">
Model Validation Error
</p>
<p className="text-sm text-destructive-fg/70 mt-1">
{validationError}
</p>
</div>
</div>
</div>
)}
{/* Validation Loading State */}
{isValidating && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-center gap-2">
<IconLoader2
size={16}
className="text-blue-500 animate-spin"
/>
<p className="text-sm text-blue-700">
Validating model file...
</p>
</div>
</div>
)}
</div>
) : (
<Button
@ -252,24 +480,73 @@ export const ImportVisionModelDialog = ({
</div>
{mmProjFile ? (
<div className="bg-accent/10 border border-accent/20 rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconCheck size={16} className="text-accent" />
<span className="text-sm font-medium text-main-view-fg">
{mmProjFile.split(/[\\/]/).pop()}
</span>
<div className="space-y-2">
<div className="bg-accent/10 border border-accent/20 rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isValidatingMmproj ? (
<IconLoader2
size={16}
className="text-accent animate-spin"
/>
) : mmprojValidationError ? (
<IconAlertTriangle
size={16}
className="text-destructive"
/>
) : (
<IconCheck size={16} className="text-accent" />
)}
<span className="text-sm font-medium text-main-view-fg">
{mmProjFile.split(/[\\/]/).pop()}
</span>
</div>
<Button
variant="link"
size="sm"
onClick={() => handleFileSelect('mmproj')}
disabled={importing || isValidatingMmproj}
className="text-accent hover:text-accent/80"
>
Change
</Button>
</div>
<Button
variant="link"
size="sm"
onClick={() => handleFileSelect('mmproj')}
disabled={importing}
className="text-accent hover:text-accent/80"
>
Change
</Button>
</div>
{/* MMProj Validation Error Display */}
{mmprojValidationError && (
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-3">
<div className="flex items-start gap-2">
<IconAlertTriangle
size={16}
className="text-destructive mt-0.5 flex-shrink-0"
/>
<div>
<p className="text-sm font-medium text-destructive-fg">
MMProj Validation Error
</p>
<p className="text-sm text-destructive-fg/70 mt-1">
{mmprojValidationError}
</p>
</div>
</div>
</div>
)}
{/* MMProj Validation Loading State */}
{isValidatingMmproj && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-center gap-2">
<IconLoader2
size={16}
className="text-blue-500 animate-spin"
/>
<p className="text-sm text-blue-700">
Validating MMProj file...
</p>
</div>
</div>
)}
</div>
) : (
<Button
@ -303,7 +580,11 @@ export const ImportVisionModelDialog = ({
importing ||
!modelFile ||
!modelName ||
(isVisionModel && !mmProjFile)
(isVisionModel && !mmProjFile) ||
validationError !== null ||
isValidating ||
mmprojValidationError !== null ||
isValidatingMmproj
}
className="flex-1"
>

View File

@ -11,7 +11,13 @@ import {
modelInfo,
} from '@janhq/core'
import { Model as CoreModel } from '@janhq/core'
import type { ModelsService, ModelCatalog, HuggingFaceRepo, CatalogModel } from './types'
import type {
ModelsService,
ModelCatalog,
HuggingFaceRepo,
CatalogModel,
ModelValidationResult,
} from './types'
// TODO: Replace this with the actual provider later
const defaultProvider = 'llamacpp'
@ -151,7 +157,9 @@ export class DefaultModelsService implements ModelsService {
async updateModel(model: Partial<CoreModel>): Promise<void> {
if (model.settings)
this.getEngine()?.updateSettings(model.settings as SettingComponentProps[])
this.getEngine()?.updateSettings(
model.settings as SettingComponentProps[]
)
}
async pullModel(
@ -266,7 +274,10 @@ export class DefaultModelsService implements ModelsService {
if (models) await Promise.all(models.map((model) => this.stopModel(model)))
}
async startModel(provider: ProviderObject, model: string): Promise<SessionInfo | undefined> {
async startModel(
provider: ProviderObject,
model: string
): Promise<SessionInfo | undefined> {
const engine = this.getEngine(provider.provider)
if (!engine) return undefined
@ -312,7 +323,10 @@ export class DefaultModelsService implements ModelsService {
async checkMmprojExistsAndUpdateOffloadMMprojSetting(
modelId: string,
updateProvider?: (providerName: string, data: Partial<ModelProvider>) => void,
updateProvider?: (
providerName: string,
data: Partial<ModelProvider>
) => void,
getProviderByName?: (providerName: string) => ModelProvider | undefined
): Promise<{ exists: boolean; settingsUpdated: boolean }> {
let settingsUpdated = false
@ -374,7 +388,8 @@ export class DefaultModelsService implements ModelsService {
(p: { provider: string }) => p.provider === 'llamacpp'
)
const model = llamacppProvider?.models?.find(
(m: { id: string; settings?: Record<string, unknown> }) => m.id === modelId
(m: { id: string; settings?: Record<string, unknown> }) =>
m.id === modelId
)
if (model?.settings) {
@ -429,7 +444,10 @@ export class DefaultModelsService implements ModelsService {
return false
}
async isModelSupported(modelPath: string, ctxSize?: number): Promise<'RED' | 'YELLOW' | 'GREEN' | 'GREY'> {
async isModelSupported(
modelPath: string,
ctxSize?: number
): Promise<'RED' | 'YELLOW' | 'GREEN' | 'GREY'> {
try {
const engine = this.getEngine('llamacpp') as AIEngine & {
isModelSupported?: (
@ -448,4 +466,29 @@ export class DefaultModelsService implements ModelsService {
return 'GREY' // Error state, assume not supported
}
}
}
async validateGgufFile(filePath: string): Promise<ModelValidationResult> {
try {
const engine = this.getEngine('llamacpp') as AIEngine & {
validateGgufFile?: (path: string) => Promise<ModelValidationResult>
}
if (engine && typeof engine.validateGgufFile === 'function') {
return await engine.validateGgufFile(filePath)
}
// If the specific method isn't available, we can fallback to a basic check
console.warn('validateGgufFile method not available in llamacpp engine')
return {
isValid: true, // Assume valid for now
error: 'Validation method not available',
}
} catch (error) {
console.error(`Error validating GGUF file ${filePath}:`, error)
return {
isValid: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
}

View File

@ -69,6 +69,18 @@ export interface HuggingFaceRepo {
readme?: string
}
export interface GgufMetadata {
version: number
tensor_count: number
metadata: Record<string, string>
}
export interface ModelValidationResult {
isValid: boolean
error?: string
metadata?: GgufMetadata
}
export interface ModelsService {
fetchModels(): Promise<modelInfo[]>
fetchModelCatalog(): Promise<ModelCatalog>
@ -104,4 +116,5 @@ export interface ModelsService {
): Promise<{ exists: boolean; settingsUpdated: boolean }>
checkMmprojExists(modelId: string): Promise<boolean>
isModelSupported(modelPath: string, ctxSize?: number): Promise<'RED' | 'YELLOW' | 'GREEN' | 'GREY'>
validateGgufFile(filePath: string): Promise<ModelValidationResult>
}