Skip to content

Commit 9c5c55e

Browse files
committed
Add runtime JSON config for preset models
Convert static ModelDownloadConfig to runtime JSON configuration: - Add preset_models.json in assets with default presets - Add PresetConfigManager for loading/parsing JSON configs - Support loading custom config from URL with caching - Add URL input, Load, and Reset buttons to preset UI - Add unit tests for JSON parsing logic
1 parent 6a66b2d commit 9c5c55e

7 files changed

Lines changed: 771 additions & 33 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"models": {
3+
"stories": {
4+
"displayName": "Stories 110M",
5+
"modelUrl": "https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114/stories110M.pte",
6+
"modelFilename": "stories110M.pte",
7+
"tokenizerUrl": "https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114/tokenizer.model",
8+
"tokenizerFilename": "tokenizer.model",
9+
"modelType": "LLAMA_3"
10+
},
11+
"llama": {
12+
"displayName": "Llama 3.2 1B",
13+
"modelUrl": "https://huggingface.co/executorch-community/Llama-3.2-1B-ET/resolve/main/llama3_2-1B.pte",
14+
"modelFilename": "llama3_2-1B.pte",
15+
"tokenizerUrl": "https://huggingface.co/executorch-community/Llama-3.2-1B-ET/resolve/main/tokenizer.model",
16+
"tokenizerFilename": "tokenizer.model",
17+
"modelType": "LLAMA_3"
18+
},
19+
"gemma": {
20+
"displayName": "Gemma 3 4B",
21+
"modelUrl": "https://huggingface.co/pytorch/gemma-3-4b-it-HQQ-INT8-INT4/resolve/main/model.pte",
22+
"modelFilename": "model.pte",
23+
"tokenizerUrl": "https://huggingface.co/pytorch/gemma-3-4b-it-HQQ-INT8-INT4/resolve/main/tokenizer.json",
24+
"tokenizerFilename": "tokenizer.json",
25+
"modelType": "GEMMA_3"
26+
}
27+
}
28+
}

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModelDownloadConfig.kt

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
package com.example.executorchllamademo
1010

11+
import android.content.Context
12+
1113
/**
1214
* Represents a downloadable model with its associated files.
1315
*/
@@ -24,45 +26,51 @@ data class ModelInfo(
2426

2527
/**
2628
* Configuration class that maps model display names to their download URLs.
29+
* Models are loaded from JSON configuration at runtime via PresetConfigManager.
2730
*/
2831
object ModelDownloadConfig {
2932

30-
private val AVAILABLE_MODELS: LinkedHashMap<String, ModelInfo> = linkedMapOf(
31-
"stories" to ModelInfo(
32-
displayName = "Stories 110M",
33-
modelUrl = "https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114/stories110M.pte",
34-
modelFilename = "stories110M.pte",
35-
tokenizerUrl = "https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114/tokenizer.model",
36-
tokenizerFilename = "tokenizer.model",
37-
modelType = ModelType.LLAMA_3
38-
),
39-
"llama" to ModelInfo(
40-
displayName = "Llama 3.2 1B",
41-
modelUrl = "https://huggingface.co/executorch-community/Llama-3.2-1B-ET/resolve/main/llama3_2-1B.pte",
42-
modelFilename = "llama3_2-1B.pte",
43-
tokenizerUrl = "https://huggingface.co/executorch-community/Llama-3.2-1B-ET/resolve/main/tokenizer.model",
44-
tokenizerFilename = "tokenizer.model",
45-
modelType = ModelType.LLAMA_3
46-
),
47-
"gemma" to ModelInfo(
48-
displayName = "Gemma 3 4B",
49-
modelUrl = "https://huggingface.co/pytorch/gemma-3-4b-it-HQQ-INT8-INT4/resolve/main/model.pte",
50-
modelFilename = "model.pte",
51-
tokenizerUrl = "https://huggingface.co/pytorch/gemma-3-4b-it-HQQ-INT8-INT4/resolve/main/tokenizer.json",
52-
tokenizerFilename = "tokenizer.json",
53-
modelType = ModelType.GEMMA_3
54-
)
55-
)
33+
private var configManager: PresetConfigManager? = null
34+
private var cachedModels: Map<String, ModelInfo> = emptyMap()
35+
36+
/**
37+
* Initializes the config with a context. Must be called before accessing models.
38+
*/
39+
fun initialize(context: Context) {
40+
if (configManager == null) {
41+
configManager = PresetConfigManager(context.applicationContext)
42+
reloadModels()
43+
}
44+
}
45+
46+
/**
47+
* Reloads models from the current configuration source.
48+
*/
49+
fun reloadModels() {
50+
cachedModels = configManager?.loadModels() ?: emptyMap()
51+
}
52+
53+
/**
54+
* Updates the models with a new map (used after loading from URL).
55+
*/
56+
fun updateModels(models: Map<String, ModelInfo>) {
57+
cachedModels = models
58+
}
59+
60+
/**
61+
* Returns the PresetConfigManager instance for advanced operations.
62+
*/
63+
fun getConfigManager(): PresetConfigManager? = configManager
5664

57-
fun getAvailableModels(): Map<String, ModelInfo> = AVAILABLE_MODELS
65+
fun getAvailableModels(): Map<String, ModelInfo> = cachedModels
5866

5967
fun getDisplayNames(): Array<String> =
60-
AVAILABLE_MODELS.values.map { it.displayName }.toTypedArray()
68+
cachedModels.values.map { it.displayName }.toTypedArray()
6169

62-
fun getModelKeys(): Array<String> = AVAILABLE_MODELS.keys.toTypedArray()
70+
fun getModelKeys(): Array<String> = cachedModels.keys.toTypedArray()
6371

6472
fun getByDisplayName(displayName: String): ModelInfo? =
65-
AVAILABLE_MODELS.values.find { it.displayName == displayName }
73+
cachedModels.values.find { it.displayName == displayName }
6674

67-
fun getByKey(key: String): ModelInfo? = AVAILABLE_MODELS[key]
75+
fun getByKey(key: String): ModelInfo? = cachedModels[key]
6876
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
package com.example.executorchllamademo
10+
11+
import android.content.Context
12+
import android.util.Log
13+
import kotlinx.coroutines.Dispatchers
14+
import kotlinx.coroutines.withContext
15+
import org.json.JSONObject
16+
import java.io.File
17+
import java.net.HttpURLConnection
18+
import java.net.URL
19+
20+
/**
21+
* Manages loading and parsing of preset model configurations from JSON.
22+
* Supports loading from bundled assets, local cache, or remote URL.
23+
*/
24+
class PresetConfigManager(private val context: Context) {
25+
26+
companion object {
27+
private const val TAG = "PresetConfigManager"
28+
private const val ASSET_FILENAME = "preset_models.json"
29+
private const val CACHE_FILENAME = "preset_models_cache.json"
30+
private const val PREFS_NAME = "preset_config_prefs"
31+
private const val PREF_CUSTOM_URL = "custom_config_url"
32+
}
33+
34+
private val cacheFile: File
35+
get() = File(context.filesDir, CACHE_FILENAME)
36+
37+
private val prefs by lazy {
38+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
39+
}
40+
41+
/**
42+
* Returns the currently configured custom URL, or null if using default.
43+
*/
44+
fun getCustomConfigUrl(): String? {
45+
return prefs.getString(PREF_CUSTOM_URL, null)
46+
}
47+
48+
/**
49+
* Saves a custom config URL to preferences.
50+
*/
51+
fun setCustomConfigUrl(url: String?) {
52+
prefs.edit().apply {
53+
if (url.isNullOrBlank()) {
54+
remove(PREF_CUSTOM_URL)
55+
} else {
56+
putString(PREF_CUSTOM_URL, url)
57+
}
58+
apply()
59+
}
60+
}
61+
62+
/**
63+
* Loads models from the current configuration source.
64+
* Priority: cached config (if custom URL was loaded) -> bundled asset
65+
*/
66+
fun loadModels(): Map<String, ModelInfo> {
67+
// If we have a cached config from a custom URL, use it
68+
if (cacheFile.exists() && getCustomConfigUrl() != null) {
69+
try {
70+
val json = cacheFile.readText()
71+
val models = parseModelsJson(json)
72+
if (models.isNotEmpty()) {
73+
Log.d(TAG, "Loaded ${models.size} models from cache")
74+
return models
75+
}
76+
} catch (e: Exception) {
77+
Log.w(TAG, "Failed to load cached config, falling back to asset", e)
78+
}
79+
}
80+
81+
// Fall back to bundled asset
82+
return loadFromAsset()
83+
}
84+
85+
/**
86+
* Loads models from the bundled asset file.
87+
*/
88+
private fun loadFromAsset(): Map<String, ModelInfo> {
89+
return try {
90+
val json = context.assets.open(ASSET_FILENAME).bufferedReader().use { it.readText() }
91+
val models = parseModelsJson(json)
92+
Log.d(TAG, "Loaded ${models.size} models from asset")
93+
models
94+
} catch (e: Exception) {
95+
Log.e(TAG, "Failed to load models from asset", e)
96+
emptyMap()
97+
}
98+
}
99+
100+
/**
101+
* Downloads config from a URL and caches it locally.
102+
* Returns the parsed models, or null if download/parse failed.
103+
*/
104+
suspend fun loadFromUrl(url: String): Result<Map<String, ModelInfo>> = withContext(Dispatchers.IO) {
105+
try {
106+
val connection = URL(url).openConnection() as HttpURLConnection
107+
connection.connectTimeout = 15000
108+
connection.readTimeout = 15000
109+
connection.requestMethod = "GET"
110+
111+
val responseCode = connection.responseCode
112+
if (responseCode != HttpURLConnection.HTTP_OK) {
113+
return@withContext Result.failure(
114+
Exception("HTTP error: $responseCode ${connection.responseMessage}")
115+
)
116+
}
117+
118+
val json = connection.inputStream.bufferedReader().use { it.readText() }
119+
val models = parseModelsJson(json)
120+
121+
if (models.isEmpty()) {
122+
return@withContext Result.failure(Exception("No valid models found in config"))
123+
}
124+
125+
// Cache the config and save the URL
126+
cacheFile.writeText(json)
127+
setCustomConfigUrl(url)
128+
129+
Log.d(TAG, "Loaded ${models.size} models from URL: $url")
130+
Result.success(models)
131+
} catch (e: Exception) {
132+
Log.e(TAG, "Failed to load config from URL: $url", e)
133+
Result.failure(e)
134+
}
135+
}
136+
137+
/**
138+
* Resets to the default bundled configuration.
139+
* Clears the cached config and custom URL.
140+
*/
141+
fun resetToDefault(): Map<String, ModelInfo> {
142+
// Delete cached config
143+
if (cacheFile.exists()) {
144+
cacheFile.delete()
145+
}
146+
// Clear custom URL
147+
setCustomConfigUrl(null)
148+
149+
Log.d(TAG, "Reset to default configuration")
150+
return loadFromAsset()
151+
}
152+
153+
/**
154+
* Parses the JSON string into a map of ModelInfo objects.
155+
* Handles invalid entries gracefully by skipping them.
156+
*/
157+
private fun parseModelsJson(json: String): Map<String, ModelInfo> {
158+
val result = linkedMapOf<String, ModelInfo>()
159+
160+
try {
161+
val root = JSONObject(json)
162+
val models = root.optJSONObject("models") ?: return emptyMap()
163+
164+
val keys = models.keys()
165+
while (keys.hasNext()) {
166+
val key = keys.next()
167+
try {
168+
val modelObj = models.getJSONObject(key)
169+
val modelInfo = parseModelInfo(modelObj)
170+
if (modelInfo != null) {
171+
result[key] = modelInfo
172+
} else {
173+
Log.w(TAG, "Skipping invalid model entry: $key")
174+
}
175+
} catch (e: Exception) {
176+
Log.w(TAG, "Error parsing model entry '$key': ${e.message}")
177+
}
178+
}
179+
} catch (e: Exception) {
180+
Log.e(TAG, "Error parsing models JSON", e)
181+
}
182+
183+
return result
184+
}
185+
186+
/**
187+
* Parses a single model JSON object into a ModelInfo.
188+
* Returns null if required fields are missing or invalid.
189+
*/
190+
private fun parseModelInfo(obj: JSONObject): ModelInfo? {
191+
val displayName = obj.optString("displayName").takeIf { it.isNotEmpty() } ?: return null
192+
val modelUrl = obj.optString("modelUrl").takeIf { it.isNotEmpty() } ?: return null
193+
val modelFilename = obj.optString("modelFilename").takeIf { it.isNotEmpty() } ?: return null
194+
val tokenizerUrl = obj.optString("tokenizerUrl", "")
195+
val tokenizerFilename = obj.optString("tokenizerFilename", "")
196+
197+
val modelTypeStr = obj.optString("modelType", "LLAMA_3")
198+
val modelType = try {
199+
ModelType.valueOf(modelTypeStr)
200+
} catch (e: IllegalArgumentException) {
201+
Log.w(TAG, "Unknown model type '$modelTypeStr', defaulting to LLAMA_3")
202+
ModelType.LLAMA_3
203+
}
204+
205+
return ModelInfo(
206+
displayName = displayName,
207+
modelUrl = modelUrl,
208+
modelFilename = modelFilename,
209+
tokenizerUrl = tokenizerUrl,
210+
tokenizerFilename = tokenizerFilename,
211+
modelType = modelType
212+
)
213+
}
214+
}

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/SelectPresetModelActivity.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class SelectPresetModelActivity : ComponentActivity() {
5959
SelectPresetModelScreen(
6060
availableModels = viewModel.availableModels,
6161
modelStates = viewModel.modelStates,
62+
configLoadState = viewModel.configLoadState,
6263
onBackPressed = { finish() },
6364
onDownloadClick = { key ->
6465
viewModel.downloadModel(key)
@@ -72,6 +73,12 @@ class SelectPresetModelActivity : ComponentActivity() {
7273
startActivity(Intent(this@SelectPresetModelActivity, MainActivity::class.java))
7374
finish()
7475
}
76+
},
77+
onLoadConfigFromUrl = { url ->
78+
viewModel.loadConfigFromUrl(url)
79+
},
80+
onResetConfig = {
81+
viewModel.resetToDefaultConfig()
7582
}
7683
)
7784
}

0 commit comments

Comments
 (0)