diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 94503b2..72846d8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,6 +59,23 @@ android { "HUGGINGFACE_API_KEY", "\"${localProperties.getProperty("HUGGINGFACE_API_KEY") ?: ""}\"" ) + // Bundled free-tier NVIDIA NIM developer key. + buildConfigField( + "String", + "NVIDIA_API_KEY", + "\"${localProperties.getProperty("NVIDIA_API_KEY") ?: ""}\"" + ) + // Bundled free-tier Cloudflare Workers AI token + account id. + buildConfigField( + "String", + "CLOUDFLARE_API_KEY", + "\"${localProperties.getProperty("CLOUDFLARE_API_KEY") ?: ""}\"" + ) + buildConfigField( + "String", + "CLOUDFLARE_ACCOUNT_ID", + "\"${localProperties.getProperty("CLOUDFLARE_ACCOUNT_ID") ?: ""}\"" + ) } lint { diff --git a/app/release/baselineProfiles/0/app-arm64-v8a-release.dm b/app/release/baselineProfiles/0/app-arm64-v8a-release.dm index cab6dd8..c91d5aa 100644 Binary files a/app/release/baselineProfiles/0/app-arm64-v8a-release.dm and b/app/release/baselineProfiles/0/app-arm64-v8a-release.dm differ diff --git a/app/release/baselineProfiles/0/app-armeabi-v7a-release.dm b/app/release/baselineProfiles/0/app-armeabi-v7a-release.dm index cab6dd8..c91d5aa 100644 Binary files a/app/release/baselineProfiles/0/app-armeabi-v7a-release.dm and b/app/release/baselineProfiles/0/app-armeabi-v7a-release.dm differ diff --git a/app/release/baselineProfiles/0/app-universal-release.dm b/app/release/baselineProfiles/0/app-universal-release.dm index cab6dd8..c91d5aa 100644 Binary files a/app/release/baselineProfiles/0/app-universal-release.dm and b/app/release/baselineProfiles/0/app-universal-release.dm differ diff --git a/app/release/baselineProfiles/0/app-x86_64-release.dm b/app/release/baselineProfiles/0/app-x86_64-release.dm index cab6dd8..c91d5aa 100644 Binary files a/app/release/baselineProfiles/0/app-x86_64-release.dm and b/app/release/baselineProfiles/0/app-x86_64-release.dm differ diff --git a/app/release/baselineProfiles/1/app-arm64-v8a-release.dm b/app/release/baselineProfiles/1/app-arm64-v8a-release.dm index f20c1f1..e684a85 100644 Binary files a/app/release/baselineProfiles/1/app-arm64-v8a-release.dm and b/app/release/baselineProfiles/1/app-arm64-v8a-release.dm differ diff --git a/app/release/baselineProfiles/1/app-armeabi-v7a-release.dm b/app/release/baselineProfiles/1/app-armeabi-v7a-release.dm index f20c1f1..e684a85 100644 Binary files a/app/release/baselineProfiles/1/app-armeabi-v7a-release.dm and b/app/release/baselineProfiles/1/app-armeabi-v7a-release.dm differ diff --git a/app/release/baselineProfiles/1/app-universal-release.dm b/app/release/baselineProfiles/1/app-universal-release.dm index f20c1f1..e684a85 100644 Binary files a/app/release/baselineProfiles/1/app-universal-release.dm and b/app/release/baselineProfiles/1/app-universal-release.dm differ diff --git a/app/release/baselineProfiles/1/app-x86_64-release.dm b/app/release/baselineProfiles/1/app-x86_64-release.dm index f20c1f1..e684a85 100644 Binary files a/app/release/baselineProfiles/1/app-x86_64-release.dm and b/app/release/baselineProfiles/1/app-x86_64-release.dm differ diff --git a/app/src/main/java/com/skeler/scanely/core/ai/AiLog.kt b/app/src/main/java/com/skeler/scanely/core/ai/AiLog.kt index 6eb0b62..b59136b 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/AiLog.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/AiLog.kt @@ -15,7 +15,11 @@ private val SECRET_PATTERNS: List> = listOf( Regex("sk-ant-[A-Za-z0-9._\\-]+") to "***", Regex("sk-or-[A-Za-z0-9._\\-]+") to "***", Regex("sk-[A-Za-z0-9._\\-]+") to "***", - Regex("AIza[A-Za-z0-9._\\-]+") to "***" + Regex("AIza[A-Za-z0-9._\\-]+") to "***", + Regex("nvapi-[A-Za-z0-9._\\-]+") to "***", + Regex("gsk_[A-Za-z0-9._\\-]+") to "***", + Regex("hf_[A-Za-z0-9._\\-]+") to "***", + Regex("cfut_[A-Za-z0-9._\\-]+") to "***" ) /** diff --git a/app/src/main/java/com/skeler/scanely/core/ai/AiProvider.kt b/app/src/main/java/com/skeler/scanely/core/ai/AiProvider.kt index 5b6eb06..707cfff 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/AiProvider.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/AiProvider.kt @@ -27,6 +27,8 @@ enum class AiProvider(val displayName: String, val kind: ProviderKind) { HUGGINGFACE("Hugging Face", ProviderKind.OPENAI_COMPAT), NVIDIA("NVIDIA", ProviderKind.OPENAI_COMPAT), GROQ("Groq", ProviderKind.OPENAI_COMPAT), + CEREBRAS("Cerebras", ProviderKind.OPENAI_COMPAT), + CLOUDFLARE("Cloudflare", ProviderKind.OPENAI_COMPAT), OPENAI("OpenAI", ProviderKind.OPENAI_COMPAT), CLAUDE("Claude", ProviderKind.ANTHROPIC), CUSTOM("Custom", ProviderKind.OPENAI_COMPAT); diff --git a/app/src/main/java/com/skeler/scanely/core/ai/KeyVerifier.kt b/app/src/main/java/com/skeler/scanely/core/ai/KeyVerifier.kt index f363235..29777b2 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/KeyVerifier.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/KeyVerifier.kt @@ -4,6 +4,8 @@ import com.skeler.scanely.core.network.ClaudeApi import com.skeler.scanely.core.network.KeyValidationApi import com.skeler.scanely.core.network.NetworkObserver import kotlinx.coroutines.CancellationException +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -21,8 +23,9 @@ sealed interface VerificationResult { /** * Verifies an API key by calling the provider's cheapest authenticated endpoint - * (typically `GET /models`) and inspecting the HTTP status. No tokens are spent - * and no user content is sent. Runs entirely off the main thread; cancellation + * (typically `GET /models`) and inspecting the HTTP status. No user content is + * sent, and no tokens are spent except NVIDIA's unavoidable 1-token probe. + * Runs entirely off the main thread; cancellation * propagates so an in-flight check can be abandoned when the key changes. */ @Singleton @@ -60,9 +63,14 @@ class KeyVerifier @Inject constructor( bearer(trimmed) ).code() - AiProvider.NVIDIA -> api.get( - "https://integrate.api.nvidia.com/v1/models", - bearer(trimmed) + // NVIDIA's GET /v1/models is public and returns 200 for any (or no) + // key, so it can't validate. The only authenticated endpoint is + // chat/completions itself; probe it with max_tokens=1 (401/403 on a + // bad key costs nothing; a valid key spends one token). + AiProvider.NVIDIA -> api.post( + "https://integrate.api.nvidia.com/v1/chat/completions", + bearer(trimmed), + NVIDIA_PROBE_BODY.toRequestBody("application/json".toMediaType()) ).code() AiProvider.GROQ -> api.get( @@ -70,6 +78,11 @@ class KeyVerifier @Inject constructor( bearer(trimmed) ).code() + AiProvider.CEREBRAS -> api.get( + "https://api.cerebras.ai/v1/models", + bearer(trimmed) + ).code() + AiProvider.OPENAI -> api.get( "https://api.openai.com/v1/models", bearer(trimmed) @@ -83,6 +96,16 @@ class KeyVerifier @Inject constructor( ) ).code() + AiProvider.CLOUDFLARE -> { + // customUrl carries the account id here (no full URL to enter). + val accountId = customUrl?.trim()?.ifBlank { null } + ?: return VerificationResult.Invalid("Enter your Account ID first") + api.get( + "https://api.cloudflare.com/client/v4/accounts/$accountId/ai/models/search", + bearer(trimmed) + ).code() + } + AiProvider.CUSTOM -> { val url = customUrl?.trim()?.ifBlank { null } ?: return VerificationResult.Invalid("Set the endpoint URL first") @@ -113,8 +136,9 @@ class KeyVerifier @Inject constructor( // Authenticated but throttled: the key itself is accepted. code == 429 -> VerificationResult.Valid code == 401 || code == 403 -> VerificationResult.Invalid("Key was rejected") - // Google returns 400 with API_KEY_INVALID rather than 401 for a bad key. - code == 400 && provider == AiProvider.GEMINI -> VerificationResult.Invalid("Key was rejected") + // Google (API_KEY_INVALID) and Cloudflare (auth code 9106) return 400, not 401, for a bad key. + code == 400 && (provider == AiProvider.GEMINI || provider == AiProvider.CLOUDFLARE) -> + VerificationResult.Invalid("Key was rejected") code in 500..599 -> VerificationResult.Failed("Provider error — try again") else -> VerificationResult.Failed("Unexpected response (HTTP $code)") } @@ -124,4 +148,9 @@ class KeyVerifier @Inject constructor( val idx = chatUrl.indexOf("/chat/completions") return if (idx >= 0) chatUrl.substring(0, idx) + "/models" else null } + + private companion object { + const val NVIDIA_PROBE_BODY = + """{"model":"google/gemma-4-31b-it","messages":[{"role":"user","content":"hi"}],"max_tokens":1}""" + } } diff --git a/app/src/main/java/com/skeler/scanely/core/ai/PayloadFactory.kt b/app/src/main/java/com/skeler/scanely/core/ai/PayloadFactory.kt index 002197e..ded2103 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/PayloadFactory.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/PayloadFactory.kt @@ -22,7 +22,7 @@ import javax.inject.Singleton * * Supported inputs: * - Images: image/png, image/jpeg, image/webp (sent directly) - * - PDF: application/pdf (first [MAX_PDF_PAGES] pages rendered to images) + * - PDF: application/pdf (first [MAX_INPUT_IMAGES] pages rendered to images) * - Text: text/plain (content inlined into the prompt) */ @Singleton @@ -35,6 +35,14 @@ internal class PayloadFactory @Inject constructor( data class Payload(val prompt: String, val images: List) fun create(uri: Uri, mode: AiMode): Payload { + val size = fileSizeBytes(uri) + if (size != null && size > MAX_FILE_SIZE_BYTES) { + throw PayloadException( + "File too large (${size / (1024 * 1024)} MB). " + + "The maximum size for an AI scan is $MAX_FILE_SIZE_MB MB." + ) + } + val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream" val prompt = AiPrompts.forMode(mode) @@ -51,7 +59,7 @@ internal class PayloadFactory @Inject constructor( Payload(prompt, images) } - mimeType.startsWith("image/") || mode != AiMode.EXTRACT_PDF_TEXT -> { + mimeType.startsWith("image/") -> { val bitmap = loadBitmapFromUri(uri) ?: throw PayloadException("Failed to load image") try { @@ -94,6 +102,16 @@ internal class PayloadFactory @Inject constructor( return Bitmap.createScaledBitmap(bitmap, width, height, true) } + /** Source byte size via file descriptor, or null when the provider won't report it. */ + private fun fileSizeBytes(uri: Uri): Long? = try { + context.contentResolver.openFileDescriptor(uri, "r")?.use { + it.statSize.takeIf { size -> size >= 0 } + } + } catch (e: Exception) { + aiDebug { "size check failed: ${e.message}" } + null + } + private fun loadFileBytes(uri: Uri): ByteArray? = try { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } catch (e: Exception) { @@ -109,7 +127,7 @@ internal class PayloadFactory @Inject constructor( } /** - * Render up to [MAX_PDF_PAGES] pages to downscaled JPEG base64 strings, + * Render up to [MAX_INPUT_IMAGES] pages to downscaled JPEG base64 strings, * recycling each page bitmap before rendering the next so peak memory is * one page rather than the whole document. */ @@ -124,7 +142,7 @@ internal class PayloadFactory @Inject constructor( ParcelFileDescriptor.open(tempFile, ParcelFileDescriptor.MODE_READ_ONLY).use { fd -> PdfRenderer(fd).use { renderer -> - val pageCount = minOf(renderer.pageCount, MAX_PDF_PAGES) + val pageCount = minOf(renderer.pageCount, MAX_INPUT_IMAGES) for (i in 0 until pageCount) { val bitmap = renderer.openPage(i).use { page -> val width = (page.width * PDF_RENDER_SCALE).toInt() @@ -155,8 +173,12 @@ internal class PayloadFactory @Inject constructor( private const val PDF_MIME_TYPE = "application/pdf" private const val TEXT_MIME_TYPE = "text/plain" - /** Cap pages/dimensions to keep request payloads (and token usage) sane. */ - private const val MAX_PDF_PAGES = 5 + /** AI-scan input limits. */ + private const val MAX_FILE_SIZE_MB = 20 + private const val MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB.toLong() * 1024 * 1024 + private const val MAX_INPUT_IMAGES = 3 + + /** Cap dimensions to keep request payloads (and token usage) sane. */ private const val MAX_IMAGE_DIMENSION = 1536 private const val JPEG_QUALITY = 85 private const val PDF_RENDER_SCALE = 2.0f diff --git a/app/src/main/java/com/skeler/scanely/core/ai/ProviderClient.kt b/app/src/main/java/com/skeler/scanely/core/ai/ProviderClient.kt index 7c25a44..edb8f87 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/ProviderClient.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/ProviderClient.kt @@ -254,21 +254,24 @@ internal class ProviderClient @Inject constructor( } val auth = "Bearer ${config.apiKey}" + // Remembered across images so a rejected default model isn't re-tried per page. + var model = config.model val results = images.map { base64 -> val document = MistralDocument( type = "image_url", image_url = "data:image/jpeg;base64,$base64" ) val response = try { - mistralApi.ocr(auth, MistralOcrRequest(config.model, document)) + mistralApi.ocr(auth, MistralOcrRequest(model, document)) } catch (e: HttpException) { // Only the app default is eligible for the older-model retry; a // user-chosen model is never silently swapped. - if (config.model == ProviderConfig.MISTRAL_OCR_DEFAULT && + if (model == ProviderConfig.MISTRAL_OCR_DEFAULT && e.code() in 400..499 && e.code() != 429 ) { - aiDebug { "Mistral ${config.model} rejected (HTTP ${e.code()}), trying $MISTRAL_OCR_FALLBACK_MODEL" } - mistralApi.ocr(auth, MistralOcrRequest(MISTRAL_OCR_FALLBACK_MODEL, document)) + aiDebug { "Mistral $model rejected (HTTP ${e.code()}), trying $MISTRAL_OCR_FALLBACK_MODEL" } + model = MISTRAL_OCR_FALLBACK_MODEL + mistralApi.ocr(auth, MistralOcrRequest(model, document)) } else throw e } response.pages @@ -286,7 +289,8 @@ internal class ProviderClient @Inject constructor( /** Minimum interval between streamed [AiEvent.Delta] emissions. */ private const val DELTA_THROTTLE_MS = 100L - /** Inline reasoning block emitted by some models; stripped from output. */ - private val THINK_BLOCK = Regex("(?s).*?\\s*") + /** Inline reasoning block emitted by some models; stripped from output. + * Also matches an unterminated block (response truncated mid-thought). */ + private val THINK_BLOCK = Regex("(?s).*?(?:\\s*|$)") } } diff --git a/app/src/main/java/com/skeler/scanely/core/ai/ProviderConfig.kt b/app/src/main/java/com/skeler/scanely/core/ai/ProviderConfig.kt index 1159948..5d3f819 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/ProviderConfig.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/ProviderConfig.kt @@ -56,14 +56,37 @@ internal class ProviderConfigResolver @Inject constructor( val model = settingValue(SettingsKeys.HUGGINGFACE_MODEL) ?: engineModel key?.let { ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, HUGGINGFACE_URL, usesBundledKey = userKey == null) } } - AiProvider.NVIDIA -> settingValue(SettingsKeys.NVIDIA_API_KEY)?.let { + AiProvider.NVIDIA -> { + val userKey = settingValue(SettingsKeys.NVIDIA_API_KEY) + val key = userKey ?: BuildConfig.NVIDIA_API_KEY.trim().ifBlank { null } val model = settingValue(SettingsKeys.NVIDIA_MODEL) ?: NVIDIA_MODEL - ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, NVIDIA_URL) + key?.let { ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, NVIDIA_URL, usesBundledKey = userKey == null) } } AiProvider.GROQ -> settingValue(SettingsKeys.GROQ_API_KEY)?.let { val model = settingValue(SettingsKeys.GROQ_MODEL) ?: GROQ_MODEL ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, GROQ_URL) } + AiProvider.CEREBRAS -> settingValue(SettingsKeys.CEREBRAS_API_KEY)?.let { + val model = settingValue(SettingsKeys.CEREBRAS_MODEL) ?: CEREBRAS_MODEL + ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, CEREBRAS_URL) + } + AiProvider.CLOUDFLARE -> { + // A token is bound to its account, so key + account id travel as a pair: + // use the user's pair when both are set, else fall back to the bundled pair. + val userKey = settingValue(SettingsKeys.CLOUDFLARE_API_KEY) + val userAccount = settingValue(SettingsKeys.CLOUDFLARE_ACCOUNT_ID) + val bundledKey = BuildConfig.CLOUDFLARE_API_KEY.trim().ifBlank { null } + val bundledAccount = BuildConfig.CLOUDFLARE_ACCOUNT_ID.trim().ifBlank { null } + val pair = when { + userKey != null && userAccount != null -> Triple(userKey, userAccount, false) + bundledKey != null && bundledAccount != null -> Triple(bundledKey, bundledAccount, true) + else -> null + } + pair?.let { (key, accountId, bundled) -> + val model = settingValue(SettingsKeys.CLOUDFLARE_MODEL) ?: CLOUDFLARE_MODEL + ProviderConfig(ProviderKind.OPENAI_COMPAT, model, key, cloudflareUrl(accountId), usesBundledKey = bundled) + } + } AiProvider.OPENAI -> settingValue(SettingsKeys.OPENAI_API_KEY)?.let { val model = settingValue(SettingsKeys.OPENAI_MODEL) ?: OPENAI_MODEL ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, OPENAI_URL) @@ -117,13 +140,20 @@ internal class ProviderConfigResolver @Inject constructor( settingsRepository.getString(SettingsKeys.GEMINI_API_KEY), settingsRepository.getString(SettingsKeys.MISTRAL_API_KEY), settingsRepository.getString(SettingsKeys.OPENROUTER_API_KEY), - settingsRepository.getString(SettingsKeys.HUGGINGFACE_API_KEY) - ) { gemini, mistral, openRouter, huggingFace -> + settingsRepository.getString(SettingsKeys.HUGGINGFACE_API_KEY), + settingsRepository.getString(SettingsKeys.NVIDIA_API_KEY), + settingsRepository.getString(SettingsKeys.CLOUDFLARE_API_KEY), + settingsRepository.getString(SettingsKeys.CLOUDFLARE_ACCOUNT_ID) + ) { keys -> buildSet { - if (gemini.isBlank() && BUNDLED_GEMINI) add(AiProvider.GEMINI) - if (mistral.isBlank() && BUNDLED_MISTRAL) add(AiProvider.MISTRAL) - if (openRouter.isBlank() && BUNDLED_OPENROUTER) add(AiProvider.OPENROUTER) - if (huggingFace.isBlank() && BUNDLED_HUGGINGFACE) add(AiProvider.HUGGINGFACE) + if (keys[0].isBlank() && BUNDLED_GEMINI) add(AiProvider.GEMINI) + if (keys[1].isBlank() && BUNDLED_MISTRAL) add(AiProvider.MISTRAL) + if (keys[2].isBlank() && BUNDLED_OPENROUTER) add(AiProvider.OPENROUTER) + if (keys[3].isBlank() && BUNDLED_HUGGINGFACE) add(AiProvider.HUGGINGFACE) + if (keys[4].isBlank() && BUNDLED_NVIDIA) add(AiProvider.NVIDIA) + // Cloudflare falls back to the bundled pair unless BOTH user fields are set + // (resolve() uses key + account id as an inseparable pair). + if ((keys[5].isBlank() || keys[6].isBlank()) && BUNDLED_CLOUDFLARE) add(AiProvider.CLOUDFLARE) } } @@ -133,6 +163,8 @@ internal class ProviderConfigResolver @Inject constructor( if (BUNDLED_MISTRAL) add(AiProvider.MISTRAL) if (BUNDLED_OPENROUTER) add(AiProvider.OPENROUTER) if (BUNDLED_HUGGINGFACE) add(AiProvider.HUGGINGFACE) + if (BUNDLED_NVIDIA) add(AiProvider.NVIDIA) + if (BUNDLED_CLOUDFLARE) add(AiProvider.CLOUDFLARE) } // Trimmed, or null if unset/blank. @@ -145,9 +177,15 @@ internal class ProviderConfigResolver @Inject constructor( private const val OPENROUTER_MODEL = "google/gemma-4-26b-a4b-it:free" private const val HUGGINGFACE_URL = "https://router.huggingface.co/v1/chat/completions" private const val NVIDIA_URL = "https://integrate.api.nvidia.com/v1/chat/completions" - private const val NVIDIA_MODEL = "meta/llama-3.2-11b-vision-instruct" + private const val NVIDIA_MODEL = "google/gemma-4-31b-it" private const val GROQ_URL = "https://api.groq.com/openai/v1/chat/completions" private const val GROQ_MODEL = "qwen/qwen3.6-27b" + private const val CEREBRAS_URL = "https://api.cerebras.ai/v1/chat/completions" + private const val CEREBRAS_MODEL = "gemma-4-31b" + private const val CLOUDFLARE_MODEL = "@cf/mistralai/mistral-small-3.1-24b-instruct" + // Workers AI OpenAI-compatible endpoint; the account id is part of the path. + fun cloudflareUrl(accountId: String) = + "https://api.cloudflare.com/client/v4/accounts/$accountId/ai/v1/chat/completions" private const val OPENAI_URL = "https://api.openai.com/v1/chat/completions" private const val OPENAI_MODEL = "gpt-4o-mini" private const val CLAUDE_MODEL = "claude-haiku-4-5-20251001" @@ -157,6 +195,9 @@ internal class ProviderConfigResolver @Inject constructor( private val BUNDLED_MISTRAL = BuildConfig.MISTRAL_API_KEY.isNotBlank() private val BUNDLED_OPENROUTER = BuildConfig.OPENROUTER_API_KEY.isNotBlank() private val BUNDLED_HUGGINGFACE = BuildConfig.HUGGINGFACE_API_KEY.isNotBlank() + private val BUNDLED_NVIDIA = BuildConfig.NVIDIA_API_KEY.isNotBlank() + private val BUNDLED_CLOUDFLARE = + BuildConfig.CLOUDFLARE_API_KEY.isNotBlank() && BuildConfig.CLOUDFLARE_ACCOUNT_ID.isNotBlank() private val FALLBACK_ORDER = listOf( AiProvider.GEMINI, @@ -165,6 +206,8 @@ internal class ProviderConfigResolver @Inject constructor( AiProvider.HUGGINGFACE, AiProvider.NVIDIA, AiProvider.GROQ, + AiProvider.CEREBRAS, + AiProvider.CLOUDFLARE, AiProvider.OPENAI, AiProvider.CLAUDE, AiProvider.CUSTOM diff --git a/app/src/main/java/com/skeler/scanely/core/ai/ProviderExecutor.kt b/app/src/main/java/com/skeler/scanely/core/ai/ProviderExecutor.kt index 55a7fd3..e65d51d 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/ProviderExecutor.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/ProviderExecutor.kt @@ -74,10 +74,16 @@ internal class ProviderExecutor @Inject constructor( } } aiDebug { "$name attempt ${attempt + 1} ok in ${SystemClock.elapsedRealtime() - start} ms" } - return if (text.isBlank()) { - ProviderOutcome.Fatal(AiResult.Error("No response generated")) + if (text.isNotBlank()) return ProviderOutcome.Success(text) + // A 200 stream that never carried an SSE data line means the endpoint + // doesn't implement streaming — degrade the remaining attempts to + // plain requests instead of failing the whole chain. + if (useStreaming && !streamedAnything[0]) { + aiDebug { "$name returned 200 with no SSE data; degrading to plain requests" } + useStreaming = false + lastFailureWasNetwork = false } else { - ProviderOutcome.Success(text) + return ProviderOutcome.Fatal(AiResult.Error("No response generated")) } } catch (e: TimeoutCancellationException) { aiDebug { "$name attempt ${attempt + 1} timed out after $timeoutMs ms" } diff --git a/app/src/main/java/com/skeler/scanely/core/di/GenerativeAiModule.kt b/app/src/main/java/com/skeler/scanely/core/di/GenerativeAiModule.kt index 53270b4..c995894 100644 --- a/app/src/main/java/com/skeler/scanely/core/di/GenerativeAiModule.kt +++ b/app/src/main/java/com/skeler/scanely/core/di/GenerativeAiModule.kt @@ -64,7 +64,9 @@ object GenerativeAiModule { fun provideOpenAiCompatApi(): OpenAiCompatApi = Retrofit.Builder() .baseUrl(OpenAiCompatApi.BASE_URL) - .client(defaultClient()) + // 90 s: a non-streaming (degraded) multi-image request waits for the whole + // body in one read; 60 s undercut the executor's 90–240 s attempt budget. + .client(defaultClient(90)) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() .create(OpenAiCompatApi::class.java) diff --git a/app/src/main/java/com/skeler/scanely/core/network/KeyValidationApi.kt b/app/src/main/java/com/skeler/scanely/core/network/KeyValidationApi.kt index 4dd00ca..43a7a82 100644 --- a/app/src/main/java/com/skeler/scanely/core/network/KeyValidationApi.kt +++ b/app/src/main/java/com/skeler/scanely/core/network/KeyValidationApi.kt @@ -1,9 +1,12 @@ package com.skeler.scanely.core.network +import okhttp3.RequestBody import okhttp3.ResponseBody import retrofit2.Response +import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.HeaderMap +import retrofit2.http.POST import retrofit2.http.Url /** @@ -21,6 +24,14 @@ interface KeyValidationApi { @HeaderMap headers: Map ): Response + /** For providers whose only authenticated endpoint is a POST (e.g. NVIDIA NIM). */ + @POST + suspend fun post( + @Url url: String, + @HeaderMap headers: Map, + @Body body: RequestBody + ): Response + companion object { // Placeholder base; the real endpoint is supplied per request via @Url. const val BASE_URL = "https://api.openai.com/" diff --git a/app/src/main/java/com/skeler/scanely/settings/data/SettingsKeys.kt b/app/src/main/java/com/skeler/scanely/settings/data/SettingsKeys.kt index 8f4a142..7045ed5 100644 --- a/app/src/main/java/com/skeler/scanely/settings/data/SettingsKeys.kt +++ b/app/src/main/java/com/skeler/scanely/settings/data/SettingsKeys.kt @@ -23,6 +23,9 @@ enum class SettingsKeys(val default: Any?) { HUGGINGFACE_API_KEY(""), // User-supplied Hugging Face token; falls back to bundled key NVIDIA_API_KEY(""), // User-supplied NVIDIA NIM key (nvapi-…) GROQ_API_KEY(""), // User-supplied Groq key (gsk_…) + CEREBRAS_API_KEY(""), // User-supplied Cerebras key (csk-…) + CLOUDFLARE_API_KEY(""), // User-supplied Cloudflare Workers AI token (cfut_…) + CLOUDFLARE_ACCOUNT_ID(""), // Cloudflare account id — folded into the run URL // Which Hugging Face OCR model to use when no manual model override is set. OCR_ENGINE("qwen"), // OcrEngine.id — "qwen" // Per-provider model overrides; blank = use the built-in default model. @@ -34,6 +37,8 @@ enum class SettingsKeys(val default: Any?) { HUGGINGFACE_MODEL(""), NVIDIA_MODEL(""), GROQ_MODEL(""), + CEREBRAS_MODEL(""), + CLOUDFLARE_MODEL(""), CUSTOM_API_KEY(""), // Custom OpenAI-compatible endpoint key CUSTOM_BASE_URL(""), // Custom endpoint URL (full chat/completions URL) CUSTOM_MODEL("") // Custom endpoint model id diff --git a/app/src/main/java/com/skeler/scanely/settings/presentation/screen/AiProvidersScreen.kt b/app/src/main/java/com/skeler/scanely/settings/presentation/screen/AiProvidersScreen.kt index 5cc0c4d..ac2ae29 100644 --- a/app/src/main/java/com/skeler/scanely/settings/presentation/screen/AiProvidersScreen.kt +++ b/app/src/main/java/com/skeler/scanely/settings/presentation/screen/AiProvidersScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.material.icons.rounded.Key import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.Memory import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Tag import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material.icons.rounded.VisibilityOff @@ -77,6 +78,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp @@ -163,6 +165,13 @@ fun AiProvidersScreen( ) } + item { + CloudflareProviderCard( + settingsViewModel = settingsViewModel, + verificationViewModel = verificationViewModel + ) + } + item { CustomProviderCard( settingsViewModel = settingsViewModel, @@ -262,7 +271,7 @@ private val PROVIDERS = listOf( provider = AiProvider.NVIDIA, settingsKey = SettingsKeys.NVIDIA_API_KEY, modelKey = SettingsKeys.NVIDIA_MODEL, - defaultModel = "meta/llama-3.2-11b-vision-instruct", + defaultModel = "google/gemma-4-31b-it", name = "NVIDIA", icon = Icons.Rounded.Memory, hint = "nvapi-…", @@ -280,6 +289,17 @@ private val PROVIDERS = listOf( getKeyUrl = "https://console.groq.com/keys", description = "Groq LPU · very fast inference. Free tier included." ), + ProviderSpec( + provider = AiProvider.CEREBRAS, + settingsKey = SettingsKeys.CEREBRAS_API_KEY, + modelKey = SettingsKeys.CEREBRAS_MODEL, + defaultModel = "gemma-4-31b", + name = "Cerebras", + icon = Icons.Rounded.Bolt, + hint = "csk-…", + getKeyUrl = "https://cloud.cerebras.ai", + description = "Cerebras · ultra-fast Gemma-4 vision inference. Free tier included." + ), ProviderSpec( provider = AiProvider.OPENAI, settingsKey = SettingsKeys.OPENAI_API_KEY, @@ -359,7 +379,7 @@ private fun ProviderCard( trailingIcon = { RevealToggle(revealed) { revealed = !revealed } }, supportingText = statusMessage(verifyState)?.let { msg -> { Text(msg) } }, visualTransformation = keyTransform(revealed), - keyboardOptions = KeyboardOptions.Default, + keyboardOptions = KEY_KEYBOARD, shape = MaterialTheme.shapes.large, interactionSource = interactionSource ) @@ -381,6 +401,109 @@ private fun ProviderCard( } } +/** + * Cloudflare Workers AI: account id + token + model. The account id is folded + * into the run URL rather than typed as a full endpoint, so it gets its own + * plain field and is passed to verification as the "custom URL". + */ +@Composable +private fun CloudflareProviderCard( + settingsViewModel: SettingsViewModel, + verificationViewModel: ProviderVerificationViewModel +) { + val uriHandler = LocalUriHandler.current + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + + var accountId by rememberSaveable { mutableStateOf("") } + var accountSeeded by rememberSaveable { mutableStateOf(false) } + var key by rememberSaveable { mutableStateOf("") } + var keySeeded by rememberSaveable { mutableStateOf(false) } + var revealed by remember { mutableStateOf(false) } + val storedAccount by settingsViewModel.getString(SettingsKeys.CLOUDFLARE_ACCOUNT_ID).collectAsState(initial = "") + val storedKey by settingsViewModel.getString(SettingsKeys.CLOUDFLARE_API_KEY).collectAsState(initial = "") + + LaunchedEffect(storedAccount, accountSeeded) { + if (!accountSeeded && storedAccount.isNotEmpty()) { + accountId = storedAccount + accountSeeded = true + } + } + LaunchedEffect(storedKey, keySeeded) { + if (!keySeeded && storedKey.isNotEmpty()) { + key = storedKey + keySeeded = true + } + } + + val entries by verificationViewModel.states.collectAsState() + val verifyState = verificationViewModel.resolve(entries[AiProvider.CLOUDFLARE], key) + + ProviderContainer(focused = focused) { + ProviderHeader( + name = "Cloudflare", + icon = Icons.Rounded.Bolt, + description = "Workers AI · vision on Cloudflare's edge. Free tier included; " + + "add your own Account ID + token to use your quota.", + verifyState = verifyState + ) + + OutlinedTextField( + value = accountId, + onValueChange = { + accountSeeded = true + accountId = it + settingsViewModel.setString(SettingsKeys.CLOUDFLARE_ACCOUNT_ID, it.trim()) + // Account id is part of the run URL — a change invalidates a prior key check. + verificationViewModel.invalidate(AiProvider.CLOUDFLARE) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Account ID") }, + placeholder = { Text("32-char account id") }, + leadingIcon = { Icon(Icons.Rounded.Tag, contentDescription = null) }, + shape = MaterialTheme.shapes.large + ) + + PlainField( + settingsViewModel = settingsViewModel, + settingsKey = SettingsKeys.CLOUDFLARE_MODEL, + label = "Model", + hint = "@cf/mistralai/mistral-small-3.1-24b-instruct", + leading = Icons.Rounded.Category + ) + + OutlinedTextField( + value = key, + onValueChange = { + keySeeded = true + key = it + settingsViewModel.setString(SettingsKeys.CLOUDFLARE_API_KEY, it.trim()) + verificationViewModel.onKeyChanged(AiProvider.CLOUDFLARE, it) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = verifyState is VerifyState.Invalid, + label = { Text("API token") }, + placeholder = { Text("cfut_…") }, + leadingIcon = { Icon(Icons.Rounded.Key, contentDescription = null) }, + trailingIcon = { RevealToggle(revealed) { revealed = !revealed } }, + supportingText = statusMessage(verifyState)?.let { msg -> { Text(msg) } }, + visualTransformation = keyTransform(revealed), + keyboardOptions = KEY_KEYBOARD, + shape = MaterialTheme.shapes.large, + interactionSource = interactionSource + ) + + ProviderActions( + verifyState = verifyState, + getKeyLabel = "Get Cloudflare token", + onVerify = { verificationViewModel.verify(AiProvider.CLOUDFLARE, key, customUrl = accountId) }, + onGetKey = { uriHandler.openUri("https://dash.cloudflare.com/profile/api-tokens") } + ) + } +} + /** * Custom OpenAI-compatible endpoint: base URL + model + key, all user-supplied, * grouped on one card that matches the built-in providers. @@ -467,6 +590,7 @@ private fun CustomProviderCard( trailingIcon = { RevealToggle(revealed) { revealed = !revealed } }, supportingText = statusMessage(verifyState)?.let { msg -> { Text(msg) } }, visualTransformation = keyTransform(revealed), + keyboardOptions = KEY_KEYBOARD, shape = MaterialTheme.shapes.large, interactionSource = interactionSource ) @@ -814,3 +938,6 @@ private fun PlainField( private fun keyTransform(revealed: Boolean): VisualTransformation = if (revealed) VisualTransformation.None else PasswordVisualTransformation() + +// Password keyboard so the IME never learns or suggests API keys. +private val KEY_KEYBOARD = KeyboardOptions(keyboardType = KeyboardType.Password) diff --git a/app/src/main/java/com/skeler/scanely/settings/presentation/viewmodel/ProviderVerificationViewModel.kt b/app/src/main/java/com/skeler/scanely/settings/presentation/viewmodel/ProviderVerificationViewModel.kt index e84d516..5feed69 100644 --- a/app/src/main/java/com/skeler/scanely/settings/presentation/viewmodel/ProviderVerificationViewModel.kt +++ b/app/src/main/java/com/skeler/scanely/settings/presentation/viewmodel/ProviderVerificationViewModel.kt @@ -85,7 +85,7 @@ class ProviderVerificationViewModel @Inject constructor( jobs[provider]?.cancel() put(provider, trimmed, VerifyState.Verifying) - jobs[provider] = viewModelScope.launch { + val job = viewModelScope.launch { val state = when (val result = verifier.verify(provider, trimmed, customUrl)) { is VerificationResult.Valid -> VerifyState.Verified is VerificationResult.Invalid -> VerifyState.Invalid(result.message) @@ -96,6 +96,10 @@ class ProviderVerificationViewModel @Inject constructor( put(provider, trimmed, state) } } + jobs[provider] = job + // Drop the entry once done so finished jobs don't accumulate; guard against + // a newer job having already replaced this one. + job.invokeOnCompletion { if (jobs[provider] === job) jobs.remove(provider) } } /** On every key edit: invalidate a stale cache and cancel any running check. */