Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,5 @@ If the app fails to analyze a label:
- Repository: https://github.com/benevolentbandwidth/ultraprocessed
- License: [LICENSE/LICENSE.md](LICENSE/LICENSE.md)
- Technical documentation: [documentation/README.md](documentation/README.md)
- Bootstrap API (Cloud Run): [backend/README.md](backend/README.md)
- Non-Android architecture guide: [documentation/00-android-app-guide.md](documentation/00-android-app-guide.md)
18 changes: 18 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ val usdaBootstrapApiKeyB64 = localProperties
.getProperty("ZEST_USDA_BOOTSTRAP_API_KEY_B64")
.orEmpty()
.trim()
val llmBootstrapUrl = localProperties
.getProperty("ZEST_LLM_BOOTSTRAP_URL")
?: providers.environmentVariable("ZEST_LLM_BOOTSTRAP_URL").orNull
?: ""
val llmBootstrapAuthToken = localProperties
.getProperty("ZEST_LLM_BOOTSTRAP_AUTH_TOKEN")
?: providers.environmentVariable("ZEST_LLM_BOOTSTRAP_AUTH_TOKEN").orNull
?: ""
val releaseStoreFile = providers.environmentVariable("ZEST_RELEASE_STORE_FILE").orNull
val releaseStorePassword = providers.environmentVariable("ZEST_RELEASE_STORE_PASSWORD").orNull
val releaseKeyAlias = providers.environmentVariable("ZEST_RELEASE_KEY_ALIAS").orNull
Expand All @@ -52,6 +60,16 @@ android {
"USDA_BOOTSTRAP_API_KEY_B64",
usdaBootstrapApiKeyB64.asBuildConfigStringLiteral(),
)
buildConfigField(
"String",
"LLM_BOOTSTRAP_URL",
llmBootstrapUrl.trim().asBuildConfigStringLiteral(),
)
buildConfigField(
"String",
"LLM_BOOTSTRAP_AUTH_TOKEN",
llmBootstrapAuthToken.trim().asBuildConfigStringLiteral(),
)

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import com.b2.ultraprocessed.network.llm.LlmUsage
import com.b2.ultraprocessed.network.llm.MultiProviderFoodLabelLlmWorkflow
import com.b2.ultraprocessed.network.llm.NovaClassification
import com.b2.ultraprocessed.network.llm.OpenAiCompatibleFoodLabelLlmWorkflow
import com.b2.ultraprocessed.network.llm.SecretLlmApiKeyProvider
import com.b2.ultraprocessed.network.llm.DefaultOrSecretLlmApiKeyProvider
import com.b2.ultraprocessed.network.usda.SecretUsdaApiKeyProvider
import com.b2.ultraprocessed.network.usda.UsdaHttpClientFactory
import com.b2.ultraprocessed.network.usda.UsdaApiService
Expand Down Expand Up @@ -316,45 +316,39 @@ class FoodAnalysisPipeline(
fun create(context: Context): FoodAnalysisPipeline {
val appContext = context.applicationContext
AnalysisDebugLogger.initialize(appContext)
val secretKeyManager = SecretKeyManager(appContext)
val llmApiKeyProvider = DefaultOrSecretLlmApiKeyProvider(secretKeyManager)
return FoodAnalysisPipeline(
ocrPipeline = MlKitOcrPipeline(appContext),
barcodeScanner = MlKitBarcodeScanner(appContext),
usdaRepository = UsdaRepository(
dataSource = UsdaApiService(
apiKeyProvider = SecretUsdaApiKeyProvider(
SecretKeyManager(appContext),
secretKeyManager,
),
client = UsdaHttpClientFactory.create(),
),
),
llmWorkflow = MultiProviderFoodLabelLlmWorkflow(
geminiWorkflow = GeminiFoodLabelLlmWorkflow(
context = appContext,
apiKeyProvider = SecretLlmApiKeyProvider(
SecretKeyManager(appContext),
),
apiKeyProvider = llmApiKeyProvider,
),
openAiWorkflow = OpenAiCompatibleFoodLabelLlmWorkflow(
context = appContext,
apiKeyProvider = SecretLlmApiKeyProvider(
SecretKeyManager(appContext),
),
apiKeyProvider = llmApiKeyProvider,
baseUrl = "https://api.openai.com/v1",
providerTag = "openai",
),
grokWorkflow = OpenAiCompatibleFoodLabelLlmWorkflow(
context = appContext,
apiKeyProvider = SecretLlmApiKeyProvider(
SecretKeyManager(appContext),
),
apiKeyProvider = llmApiKeyProvider,
baseUrl = "https://api.x.ai/v1",
providerTag = "grok",
),
groqWorkflow = OpenAiCompatibleFoodLabelLlmWorkflow(
context = appContext,
apiKeyProvider = SecretLlmApiKeyProvider(
SecretKeyManager(appContext),
),
apiKeyProvider = llmApiKeyProvider,
baseUrl = "https://api.groq.com/openai/v1",
providerTag = "groq",
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.b2.ultraprocessed.network.bootstrap

import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject

class LlmBootstrapClient(
private val bootstrapUrl: String,
private val authToken: String,
private val packageName: String,
private val client: OkHttpClient = defaultClient(),
) {
suspend fun fetchDefaultApiKey(): Result<String> = withContext(Dispatchers.IO) {
val url = bootstrapUrl.trim()
if (url.isBlank()) {
return@withContext Result.failure(IllegalStateException("Bootstrap URL is not configured."))
}
if (!url.startsWith("https://", ignoreCase = true)) {
return@withContext Result.failure(IllegalStateException("Bootstrap URL must use HTTPS."))
}

val requestBuilder = Request.Builder()
.url(url)
.get()
.header("Accept", "application/json")

val token = authToken.trim()
if (token.isNotBlank()) {
requestBuilder.header("Authorization", "Bearer $token")
}

val androidPackage = packageName.trim()
if (androidPackage.isNotBlank()) {
requestBuilder.header("X-Android-Package", androidPackage)
}

runCatching {
client.newCall(requestBuilder.build()).execute().use { response ->
val body = response.body?.string().orEmpty()
if (!response.isSuccessful) {
throw IllegalStateException("Bootstrap request failed with HTTP ${response.code}.")
}
parseBootstrapApiKey(body)
}
}
}

companion object {
fun parseBootstrapApiKey(body: String): String {
val apiKey = JSONObject(body).optString("apiKey").trim()
if (apiKey.isBlank()) {
throw IllegalStateException("Bootstrap response did not include an API key.")
}
return apiKey
}

private fun defaultClient(): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.callTimeout(12, TimeUnit.SECONDS)
.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,20 @@ class SecretLlmApiKeyProvider(
secretKeyManager.getApiKey(SecretKeyManager.LLM_API_KEY).orEmpty().trim()
}

class DefaultOrSecretLlmApiKeyProvider(
private val secretKeyManager: SecretKeyManager,
) : LlmApiKeyProvider {
override fun getApiKey(): String = resolveActiveLlmApiKey(secretKeyManager)
}

fun resolveActiveLlmApiKey(secretKeyManager: SecretKeyManager): String {
val userKey = secretKeyManager.getApiKey(SecretKeyManager.LLM_API_KEY).orEmpty().trim()
if (userKey.isNotBlank()) {
return userKey
}
return secretKeyManager.getApiKey(SecretKeyManager.LLM_DEFAULT_API_KEY).orEmpty().trim()
}

fun hasAnyLlmApiKey(secretKeyManager: SecretKeyManager): Boolean =
resolveActiveLlmApiKey(secretKeyManager).isNotBlank()

Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class SecretKeyManager(context: Context) {

companion object {
const val LLM_API_KEY: String = "llm_api_key"
const val LLM_DEFAULT_API_KEY: String = "llm_default_api_key"
const val USDA_API_KEY: String = "usda_api_key"
}
}
134 changes: 96 additions & 38 deletions app/src/main/java/com/b2/ultraprocessed/ui/UltraProcessedApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.b2.ultraprocessed.BuildConfig
import com.b2.ultraprocessed.storage.preferences.AppPreferences
import com.b2.ultraprocessed.network.bootstrap.LlmBootstrapClient
import com.b2.ultraprocessed.network.llm.DefaultOrSecretLlmApiKeyProvider
import com.b2.ultraprocessed.network.llm.LlmApiKeyVerifier
import com.b2.ultraprocessed.network.llm.LlmProviderResolver
import com.b2.ultraprocessed.network.llm.ResultChatContext
import com.b2.ultraprocessed.network.llm.ResultChatIngredientSignal
import com.b2.ultraprocessed.network.llm.ResultChatWorkflowFactory
import com.b2.ultraprocessed.network.llm.SecretLlmApiKeyProvider
import com.b2.ultraprocessed.network.llm.hasAnyLlmApiKey
import com.b2.ultraprocessed.network.llm.resolveActiveLlmApiKey
import com.b2.ultraprocessed.storage.room.NovaDatabase
import com.b2.ultraprocessed.storage.room.ScanResult as ScanResultEntity
import com.b2.ultraprocessed.storage.secrets.SecretKeyManager
Expand Down Expand Up @@ -63,7 +66,7 @@ fun UltraProcessedApp(
val resultChatWorkflow = remember(appContext) {
ResultChatWorkflowFactory.create(
context = appContext,
apiKeyProvider = SecretLlmApiKeyProvider(secretKeyManager),
apiKeyProvider = DefaultOrSecretLlmApiKeyProvider(secretKeyManager),
)
}
val llmApiKeyVerifier = remember { LlmApiKeyVerifier() }
Expand Down Expand Up @@ -162,25 +165,19 @@ fun UltraProcessedApp(
secretKeyManager.saveApiKey(SecretKeyManager.USDA_API_KEY, bootstrapUsdaKey)
}
}
val savedLlmKey = runCatching {
secretKeyManager.getApiKey(SecretKeyManager.LLM_API_KEY)
}.getOrNull().orEmpty()
val provider = LlmProviderResolver.detectProvider(savedLlmKey)
hasLlmApiKey = savedLlmKey.isNotBlank()
llmKeyMetadata = provider?.let { provider ->
LlmProviderResolver.defaultModelForProvider(provider)?.let {
KeyMetadata(
modelName = it.modelName,
provider = it.provider,
acceptsImages = it.acceptsImages,
)
}
}
provider?.let {
LlmProviderResolver.defaultModelForProvider(it)?.let { model ->
selectedModelId = model.modelId
}
}

bootstrapDefaultLlmKeyIfNeeded(
secretKeyManager = secretKeyManager,
bootstrapUrl = BuildConfig.LLM_BOOTSTRAP_URL,
authToken = BuildConfig.LLM_BOOTSTRAP_AUTH_TOKEN,
packageName = appContext.packageName,
)

val llmUiState = llmKeyUiStateFromSecrets(secretKeyManager)
hasLlmApiKey = llmUiState.hasKey
llmKeyMetadata = llmUiState.metadata
llmUiState.selectedModelId?.let { selectedModelId = it }

hasUsdaApiKey = runCatching {
secretKeyManager.hasApiKey(SecretKeyManager.USDA_API_KEY)
}.getOrDefault(false)
Expand Down Expand Up @@ -394,20 +391,10 @@ fun UltraProcessedApp(
)
}
val saved = secretKeyManager.saveApiKey(SecretKeyManager.LLM_API_KEY, key)
hasLlmApiKey = saved &&
secretKeyManager.hasApiKey(SecretKeyManager.LLM_API_KEY)
llmKeyMetadata = provider?.let { providerId ->
LlmProviderResolver.defaultModelForProvider(providerId)?.let {
KeyMetadata(
modelName = it.modelName,
provider = it.provider,
acceptsImages = it.acceptsImages,
)
}
}
LlmProviderResolver.defaultModelForProvider(provider)?.let { model ->
selectedModelId = model.modelId
}
val llmUiState = llmKeyUiStateFromSecrets(secretKeyManager)
hasLlmApiKey = saved && llmUiState.hasKey
llmKeyMetadata = llmUiState.metadata
llmUiState.selectedModelId?.let { selectedModelId = it }
if (saved) {
KeySaveResult(
success = true,
Expand All @@ -429,7 +416,7 @@ fun UltraProcessedApp(
onLlmApiKeyPing = { typedKey ->
val keyToPing = typedKey
?.takeIf { it.isNotBlank() }
?: secretKeyManager.getApiKey(SecretKeyManager.LLM_API_KEY).orEmpty()
?: resolveActiveLlmApiKey(secretKeyManager)
if (keyToPing.isBlank()) {
KeySaveResult(
success = false,
Expand All @@ -447,8 +434,24 @@ fun UltraProcessedApp(
runCatching {
val deleted = secretKeyManager.deleteApiKey(SecretKeyManager.LLM_API_KEY)
if (deleted) {
hasLlmApiKey = false
llmKeyMetadata = null
val llmUiState = llmKeyUiStateFromSecrets(secretKeyManager)
hasLlmApiKey = llmUiState.hasKey
llmKeyMetadata = llmUiState.metadata
llmUiState.selectedModelId?.let { selectedModelId = it }
if (!secretKeyManager.hasApiKey(SecretKeyManager.LLM_DEFAULT_API_KEY)) {
coroutineScope.launch {
bootstrapDefaultLlmKeyIfNeeded(
secretKeyManager = secretKeyManager,
bootstrapUrl = BuildConfig.LLM_BOOTSTRAP_URL,
authToken = BuildConfig.LLM_BOOTSTRAP_AUTH_TOKEN,
packageName = appContext.packageName,
)
val refreshedState = llmKeyUiStateFromSecrets(secretKeyManager)
hasLlmApiKey = refreshedState.hasKey
llmKeyMetadata = refreshedState.metadata
refreshedState.selectedModelId?.let { selectedModelId = it }
}
}
}
deleted
}.getOrDefault(false)
Expand Down Expand Up @@ -509,6 +512,61 @@ private fun decodeBootstrapSecret(encoded: String): String {
}.getOrDefault("")
}

private data class LlmKeyUiState(
val hasKey: Boolean,
val metadata: KeyMetadata?,
val selectedModelId: String?,
)

private suspend fun bootstrapDefaultLlmKeyIfNeeded(
secretKeyManager: SecretKeyManager,
bootstrapUrl: String,
authToken: String,
packageName: String,
) {
if (secretKeyManager.hasApiKey(SecretKeyManager.LLM_API_KEY)) {
return
}
if (secretKeyManager.hasApiKey(SecretKeyManager.LLM_DEFAULT_API_KEY)) {
return
}

val url = bootstrapUrl.trim()
if (url.isBlank()) {
return
}

runCatching {
LlmBootstrapClient(
bootstrapUrl = url,
authToken = authToken,
packageName = packageName,
).fetchDefaultApiKey().getOrNull()
}.getOrNull()?.let { apiKey ->
secretKeyManager.saveApiKey(SecretKeyManager.LLM_DEFAULT_API_KEY, apiKey)
}
}

private fun llmKeyUiStateFromSecrets(secretKeyManager: SecretKeyManager): LlmKeyUiState {
val activeKey = resolveActiveLlmApiKey(secretKeyManager)
val provider = LlmProviderResolver.detectProvider(activeKey)
val metadata = provider?.let { providerId ->
LlmProviderResolver.defaultModelForProvider(providerId)?.let {
KeyMetadata(
modelName = it.modelName,
provider = it.provider,
acceptsImages = it.acceptsImages,
)
}
}
val modelId = provider?.let { LlmProviderResolver.defaultModelForProvider(it)?.modelId }
return LlmKeyUiState(
hasKey = hasAnyLlmApiKey(secretKeyManager),
metadata = metadata,
selectedModelId = modelId,
)
}

private fun ScanResultUi.toScanResultEntity(): ScanResultEntity =
ScanResultEntity(
productName = productName,
Expand Down
Loading
Loading