kazeia/kazeia-android/app/src/main/java/com/kazeia/config/ModelRegistry.kt

138 lines
5.6 KiB
Kotlin

package com.kazeia.config
import java.io.File
/**
* Catalogue figé des modèles LLM qu'on sait charger via ExecuTorch QNN HTP.
* Filtrer par `availableOnDisk()` pour ne proposer dans l'UI admin que ceux
* réellement présents sur la tablette.
*
* L'admin app lit cette liste via le ContentProvider URI `/models` ;
* KazeiaService la consulte pour résoudre un `model_id` → (pte, tokenizer).
*/
object ModelRegistry {
// Racine .pte LLM — résolue par KazeiaPaths (legacy /data/local/tmp/kazeia-et
// sur tablette dev, stockage externe app sur tablette de production).
private val ET_DIR: String get() = com.kazeia.KazeiaApplication.LLM_DIR
// Racine GGUF (moteur kazeia-engine "lib"). Les GGUF (q35-lmq4, qwen2.5-7b…)
// vivent sous MODELS_DIR, pas LLM_DIR.
private val GGUF_DIR: String get() = com.kazeia.KazeiaApplication.MODELS_DIR
enum class Role { SPEAKER, THINKER, BOTH }
// PTE = runner ExecuTorch QNN HTP ; GGUF = moteur kazeia-engine "lib" (CPU).
enum class Backend { PTE, GGUF }
// Template ChatML du modèle. QWEN35_THINKOFF injecte le bloc <think></think>
// (thinking-off déterministe Qwen3.5). PLAIN_CHATML = ChatML standard sans
// <think> (Qwen2.5 et autres modèles dense sans raisonnement).
enum class ChatTemplate { QWEN35_THINKOFF, PLAIN_CHATML }
data class ModelInfo(
val id: String,
val displayName: String,
val pteFile: String,
val tokenizerFile: String,
val role: Role,
val sizeMb: Int,
val maxSeqLen: Int,
val notes: String = "",
val backend: Backend = Backend.PTE,
// Nom du fichier GGUF (sous GGUF_DIR) quand backend = GGUF. Le tokenizer
// est embarqué dans le GGUF, pas de fichier séparé.
val ggufFile: String = "",
val chatTemplate: ChatTemplate = ChatTemplate.QWEN35_THINKOFF
) {
fun ptePath() = "$ET_DIR/$pteFile"
fun tokenizerPath() = "$ET_DIR/$tokenizerFile"
fun ggufPath() = "$GGUF_DIR/$ggufFile"
fun fileExists(): Boolean = when (backend) {
Backend.PTE -> File(ptePath()).exists()
Backend.GGUF -> File(ggufPath()).exists()
}
}
// Catalogue refonte 2026-06-17 (cf dist/LLM_INTEGRATION.md §2). Chargement unifié
// via UnifiedLlmAdapter (magic-byte) : GGUF→CPU i8mm, .pte→NPU V79 (libkazeia_pte).
// .pte en SOUS-DOSSIER <id>/hybrid_llama_qnn.pte (+ tokenizer.json) : le nom du
// dossier porte l'auto-détection decoder_model_version (qwen3/qwen2_5) côté natif.
val ALL: List<ModelInfo> = listOf(
ModelInfo(
id = "qwen3.5-4b",
displayName = "Qwen3.5-4B (GGUF) ★",
pteFile = "",
tokenizerFile = "",
role = Role.SPEAKER,
sizeMb = 2380,
maxSeqLen = 4096,
notes = "Speaker PAR DÉFAUT — hybride GatedDeltaNet (non exportable .pte), GGUF CPU i8mm. Thinking-off.",
backend = Backend.GGUF,
ggufFile = "q35-lmq4.gguf",
chatTemplate = ChatTemplate.QWEN35_THINKOFF
),
ModelInfo(
id = "qwen3-4b",
displayName = "Qwen3-4B (.pte NPU)",
pteFile = "qwen3_4b_seq1024/hybrid_llama_qnn.pte",
tokenizerFile = "qwen3_4b_seq1024/tokenizer.json",
role = Role.SPEAKER,
sizeMb = 3100,
maxSeqLen = 1024,
notes = "Dense Qwen3-4B sur NPU. Prefill 313 tok/s, RAM ÷2, sans root. Decode 15.7.",
backend = Backend.PTE,
chatTemplate = ChatTemplate.QWEN35_THINKOFF
),
ModelInfo(
id = "qwen3-8b",
displayName = "Qwen3-8B (.pte NPU)",
pteFile = "qwen3_8b_seq512/hybrid_llama_qnn.pte",
tokenizerFile = "qwen3_8b_seq512/tokenizer.json",
role = Role.SPEAKER,
sizeMb = 5500,
maxSeqLen = 512,
notes = "Dense Qwen3-8B sur NPU. Decode 10.5, prefill 219, RAM ~3.1 G.",
backend = Backend.PTE,
chatTemplate = ChatTemplate.QWEN35_THINKOFF
),
ModelInfo(
id = "qwen2.5-7b",
displayName = "Qwen2.5-7B (.pte NPU)",
pteFile = "qwen2_5_7b_seq512/hybrid_llama_qnn.pte",
tokenizerFile = "qwen2_5_7b_seq512/tokenizer.json",
role = Role.SPEAKER,
sizeMb = 5000,
maxSeqLen = 512,
notes = "Dense Qwen2.5-7B sur NPU (decoder qwen2_5). Decode 8.0 (embeddings non-tied), RAM ~5.1 G.",
backend = Backend.PTE,
chatTemplate = ChatTemplate.PLAIN_CHATML
),
ModelInfo(
id = "guard4b",
displayName = "Qwen3Guard 4B (.pte NPU)",
pteFile = "guard4b/hybrid_llama_qnn.pte",
tokenizerFile = "guard4b/tokenizer.json",
role = Role.THINKER,
sizeMb = 3100,
maxSeqLen = 512,
notes = "Thinker cascade sur NPU. Decode 17.2, prefill 166. Guard-4B + Speaker tiennent en 16 G.",
backend = Backend.PTE,
chatTemplate = ChatTemplate.QWEN35_THINKOFF
)
)
fun availableOnDisk(): List<ModelInfo> = ALL.filter { it.fileExists() }
fun byId(id: String): ModelInfo? = ALL.firstOrNull { it.id == id }
// Défaut Speaker = Qwen3.5-4B GGUF (hybride, décision refonte 2026-06-17).
// Repli : tout autre Speaker présent sur disque.
fun defaultSpeaker(): ModelInfo = byId("qwen3.5-4b")
?: ALL.first { it.role == Role.SPEAKER && it.fileExists() }
// Défaut Thinker (cascade) = Guard-4B .pte NPU.
fun defaultThinker(): ModelInfo = byId("guard4b")
?: ALL.first { it.role == Role.THINKER && it.fileExists() }
}