diff --git a/CHANGELOG.md b/CHANGELOG.md index ae12bd3..1bfecde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to Sonario for Android are documented here. +## 1.4.0 + +- Replaces the retired Llama 4 Scout cloud model with the fixed Groq model + `qwen/qwen3.6-27b`. +- Automatically ignores and migrates stale model IDs stored by older installs or + saved sessions. +- Resizes cloud chunks and output budgets so each request fits beneath Qwen's + free-tier 8K-token-per-minute limit. +- Queues requests against conservative local TPM, RPM, and daily budgets instead + of repeatedly hitting minute-based 429 errors. +- Reads Groq's live remaining-token and reset headers and displays a countdown + while waiting for the provider's actual token window. +- Detects organization-wide daily exhaustion, stops without retrying all day, and + preserves completed checkpoints for Resume. +- Uses Qwen's non-thinking mode for routine summaries to reduce unnecessary token + consumption. + ## 1.3.3 - Keeps the Ask field visible when the software keyboard opens. diff --git a/README.md b/README.md index 47202d8..82af606 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ or a bulleted outline. **Status:** working. YouTube transcript summarization, web-article and pasted-text summarization, Groq cloud, on-device inference, saved sessions, resumable -checkpoints, and source Q&A are functional as of version 1.3.3. +checkpoints, and source Q&A are functional as of version 1.4.0. Sonario has two engines, and you pick which to use per summary: - **Groq cloud** (recommended) - sends your text to Groq's API and summarizes - with a large model (Llama 4 Scout by default) in seconds. Fast, handles big - documents in one pass. You bring your own free API key. Your text goes to - Groq's servers. + with Qwen 3.6 27B. Sonario splits large sources into rate-safe requests, waits + for Groq's minute windows, and checkpoints each completed call. You bring your + own API key. Your text goes to Groq's servers. - **On-device** - runs a model locally via llama.cpp. Private (nothing leaves the phone except fetching the link), but slow: CPU-only, so a summary takes minutes and the phone warms up. A private fallback rather than the daily driver. @@ -22,6 +22,10 @@ Sonario has two engines, and you pick which to use per summary: - Summarizes YouTube captions, web articles, pasted text, PDF, EPUB, DOCX, TXT, and Markdown files. +- Uses `qwen/qwen3.6-27b` as the only Groq cloud model, preventing retired model + IDs from being restored by an old setting or saved session. +- Queues cloud requests against conservative TPM/RPM limits and Groq's live reset + headers instead of repeatedly failing with minute-based 429 errors. - Keeps long cloud summaries alive with a foreground service and retries temporary DNS, timeout, and network-handoff failures. - Saves up to 12 recent sessions locally, restores the latest session on launch, @@ -51,11 +55,17 @@ Sonario has two engines, and you pick which to use per summary: 1. Install the APK (see below) and open Sonario. 2. On the first screen, tap **Use Groq cloud instead** (skips the local-model download). -3. Get a free Groq API key at console.groq.com (no credit card). Create a key. +3. Get a Groq API key at console.groq.com and create a key. 4. In Sonario's Settings, paste the key and tap **Save key**. 5. Back on the main screen, make sure the toggle is on **Groq cloud**, paste a YouTube link or article URL, and tap **Summarize**. +Qwen 3.6 27B's published Groq free-tier baseline is 8K tokens per minute and +200K tokens per day, applied across the whole Groq organization. Sonario uses +slightly lower internal working limits for safety, follows the provider's live +remaining-token/reset headers, and displays a countdown between calls. Extra +keys in the same organization share the same quota and do not multiply it. + ## Quick start (on-device, fully private) 1. Open Sonario. On the first screen, tap **Get** on a model (Qwen2.5 1.5B is the @@ -107,18 +117,31 @@ no captions. These are still undocumented YouTube endpoints, so a future YouTube change can require another extractor update. Failed requests show **Extractor build 2** diagnostics so you can confirm the new APK is actually installed. +## Qwen cloud and rate-aware queueing (1.4.0) + +- The Groq cloud path is pinned to `qwen/qwen3.6-27b`; old model preferences and + saved Scout session IDs can no longer control the request model. +- Source chunks, detailed-output budgets, chapter excerpts, and Ask excerpts are + sized so one call fits beneath the free-tier 8K TPM ceiling. +- Sonario leaves headroom below the published TPM/RPM/daily limits, reads Groq's + `x-ratelimit-remaining-tokens` and reset headers, and waits with a visible + countdown before the next call. +- Routine summaries use Qwen's non-thinking mode to avoid spending output tokens + on hidden reasoning that is unnecessary for summarization. +- When Groq reports daily exhaustion, Sonario stops rather than waiting all day. + Every completed call remains checkpointed so Resume continues later. ## Background reliability and Ask fixes (1.2.0) -- Cloud requests now retry transient DNS, Wi-Fi/mobile-data handoff, connection, - and timeout failures for up to ten minutes instead of immediately ending with +- Cloud requests retry transient DNS, Wi-Fi/mobile-data handoff, connection, and + timeout failures for up to ten minutes instead of immediately ending with `Unable to resolve host api.groq.com`. - A foreground service holds a partial CPU wake lock and a temporary Wi-Fi lock only while a summary or source question is active. The notification displays rate-limit waits and network-retry status. - Groq responses are buffered before being committed to a summary, so a failed connection can be retried without duplicating a partial response. -- The Ask box now shows the actual API/network error in place, keeps the typed +- The Ask box shows the actual API/network error in place, keeps the typed question after a failure, and searches relevant passages across the whole source instead of sending only the first portion of a long video or book. - Long jobs have a visible Cancel control, stale errors clear when a new source is @@ -153,12 +176,13 @@ and the summarize pipeline talks only to that: - `llm/InferenceEngine.kt` - shared interface (`ensureReady`, `stream`). - `llm/LlmEngine.kt` - on-device via Llamatik/llama.cpp. -- `llm/GroqEngine.kt` - Groq cloud via the OpenAI-compatible streaming API. +- `llm/GroqEngine.kt` - Qwen 3.6 through Groq's OpenAI-compatible streaming API. +- `llm/RateLimiter.kt` - local pacing plus synchronization with Groq reset headers. - `llm/ModelDownloader.kt` - resumable GGUF download. -- `data/Settings.kt` - engine choice, Groq key, Groq model (local prefs). +- `data/Settings.kt` - engine choice and Groq key (local preferences). - `source/SourceFetcher.kt` - YouTube (InnerTube) and web-article fetching. -- `summarize/SummarizeEngine.kt` - map-reduce summarizer; chunking adapts to the - engine (small bounded chunks on-device, large/one-pass for the 128k cloud model). +- `summarize/SummarizeEngine.kt` - map-reduce summarizer with bounded cloud calls + sized for Groq's TPM limits and small CPU-bounded on-device chunks. - `summarize/Prompts.kt` - prompts carried over from Sonario desktop. - `ui/` - Compose screens, theme, settings, the CPU/RAM meter, crash screen. - `CrashReporter.kt` - global uncaught-exception logger. @@ -182,13 +206,22 @@ MIT. See LICENSE. ## Clear all saved sessions (1.3.1) -The Recent sessions card now has a **Clear** button. After confirmation, it permanently deletes all locally saved session folders, including source transcripts, chapter data, summaries, checkpoints, and saved Q&A. Exported files outside the app are left alone. +The Recent sessions card has a **Clear** button. After confirmation, it +permanently deletes all locally saved session folders, including source +transcripts, chapter data, summaries, checkpoints, and saved Q&A. Exported files +outside the app are left alone. ## Saved and resumable sessions (1.3.0) -Sonario now saves summaries locally instead of keeping the only copy in an Activity/ViewModel. The most recent session is restored after an app or Activity restart, and a Recent sessions panel can open, resume, or delete prior work. +Sonario saves summaries locally instead of keeping the only copy in an +Activity/ViewModel. The most recent session is restored after an app or Activity +restart, and a Recent sessions panel can open, resume, or delete prior work. -For long summaries, Sonario checkpoints after every completed LLM call. If Android or the network interrupts the run, Resume skips already-completed condensed chunks and derived views so those Groq tokens are not spent twice. Completed source text and Ask history are stored with the session. Up to 12 recent sessions are retained in the app's private files directory. +For long summaries, Sonario checkpoints after every completed LLM call. If +Android or the network interrupts the run, Resume skips already-completed +condensed chunks and derived views so those Groq tokens are not spent twice. +Completed source text and Ask history are stored with the session. Up to 12 +recent sessions are retained in the app's private files directory. ## Keyboard-safe Ask field (1.3.3) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4a2cb50..92f29bd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "ai.sonario.app" minSdk = 28 // Android 9. 8 Elite phones are far above this. targetSdk = 36 - versionCode = 9 - versionName = "1.3.3" + versionCode = 10 + versionName = "1.4.0" vectorDrawables { useSupportLibrary = true } ndk { abiFilters += "arm64-v8a" } // modern phones; keeps APK lean } diff --git a/app/src/main/java/ai/sonario/app/data/Settings.kt b/app/src/main/java/ai/sonario/app/data/Settings.kt index 59acb02..75947d0 100644 --- a/app/src/main/java/ai/sonario/app/data/Settings.kt +++ b/app/src/main/java/ai/sonario/app/data/Settings.kt @@ -5,9 +5,9 @@ import android.content.Context enum class EngineChoice { ON_DEVICE, GROQ } /** - * Simple local settings, backed by SharedPreferences. Stores the engine choice, - * the user's Groq API key, and the Groq model string. The key never leaves the - * device except in the Authorization header of requests the user initiates. + * Simple local settings, backed by SharedPreferences. Stores the engine choice + * and the user's Groq API key. Cloud inference is intentionally pinned to one + * supported model so stale saved sessions cannot restore a retired model ID. */ class Settings(context: Context) { private val prefs = context.applicationContext @@ -24,11 +24,21 @@ class Settings(context: Context) { get() = prefs.getString(KEY_GROQ_KEY, null) set(v) = prefs.edit().putString(KEY_GROQ_KEY, v?.trim()).apply() + /** + * Kept as a property for saved-session compatibility, but Sonario no longer + * accepts an arbitrary cloud model. Reading or writing this value always + * migrates it to the current fixed model. + */ var groqModel: String - get() = prefs.getString(KEY_GROQ_MODEL, DEFAULT_GROQ_MODEL) - ?: DEFAULT_GROQ_MODEL - set(v) = prefs.edit().putString(KEY_GROQ_MODEL, - v.trim().ifBlank { DEFAULT_GROQ_MODEL }).apply() + get() { + if (prefs.getString(KEY_GROQ_MODEL, null) != DEFAULT_GROQ_MODEL) { + prefs.edit().putString(KEY_GROQ_MODEL, DEFAULT_GROQ_MODEL).apply() + } + return DEFAULT_GROQ_MODEL + } + set(@Suppress("UNUSED_PARAMETER") value) { + prefs.edit().putString(KEY_GROQ_MODEL, DEFAULT_GROQ_MODEL).apply() + } val hasGroqKey: Boolean get() = !groqApiKey.isNullOrBlank() @@ -36,7 +46,7 @@ class Settings(context: Context) { private const val KEY_ENGINE = "engine" private const val KEY_GROQ_KEY = "groq_api_key" private const val KEY_GROQ_MODEL = "groq_model" - // Default model. Groq's lineup changes; this is user-editable in Settings. - const val DEFAULT_GROQ_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct" + + const val DEFAULT_GROQ_MODEL = "qwen/qwen3.6-27b" } } diff --git a/app/src/main/java/ai/sonario/app/llm/GroqEngine.kt b/app/src/main/java/ai/sonario/app/llm/GroqEngine.kt index df78d92..6b14a0b 100644 --- a/app/src/main/java/ai/sonario/app/llm/GroqEngine.kt +++ b/app/src/main/java/ai/sonario/app/llm/GroqEngine.kt @@ -3,6 +3,7 @@ package ai.sonario.app.llm import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities +import ai.sonario.app.data.Settings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -26,10 +27,10 @@ import kotlin.math.min /** * Cloud inference through Groq's OpenAI-compatible chat-completions API. * - * Requests are deliberately buffered before tokens are emitted. That lets Sonario - * safely retry a request when Android briefly suspends Wi-Fi, changes networks, or - * loses DNS while the app is in the background, without appending a duplicated - * partial answer to the UI. + * Sonario's cloud path is pinned to Qwen 3.6 27B. Responses are buffered before + * tokens are exposed so a transient failure can be retried without duplicating a + * partial answer. Provider rate-limit headers are fed back into [RateLimiter], + * which queues the next call until Groq's real token window has reset. */ class GroqEngine( context: Context, @@ -66,17 +67,20 @@ class GroqEngine( val key = apiKeyProvider()?.trim() ?.takeIf { it.isNotEmpty() } ?: throw IllegalStateException("No Groq API key is set.") - val model = modelProvider().trim() - if (model.isEmpty()) throw IllegalStateException("No Groq model is selected.") + + // Reading the provider performs the one-time migration of stale saved + // model preferences. The request itself is always pinned to Qwen. + modelProvider() + val model = Settings.DEFAULT_GROQ_MODEL val estimatedInput = RateLimiter.estimateTokens(system) + RateLimiter.estimateTokens(user) - val estimatedTotal = estimatedInput + maxTokens + val estimatedTotal = estimatedInput + maxTokens.toLong() rateLimiter?.awaitSlot(estimatedTotal) { seconds -> onRateWait(seconds) } - onRateWait(0) + onRateWait(0L) val payload = buildPayload(model, system, user, maxTokens) val parsed = performRequestWithRetry(key, payload, estimatedTotal) @@ -84,8 +88,8 @@ class GroqEngine( rateLimiter?.record(parsed.usageTokens ?: estimatedTotal) onNetworkStatus(null) - // Preserve the InferenceEngine streaming contract while only exposing - // a response after it has completed successfully. + // Preserve the streaming contract while publishing only a complete, + // successfully buffered response. parsed.text.chunked(96).forEach { emit(it) } }.flowOn(Dispatchers.IO) @@ -95,9 +99,10 @@ class GroqEngine( estimatedTotal: Long, ): ParsedCompletion { val media = "application/json; charset=utf-8".toMediaTypeOrNull() - val deadline = System.currentTimeMillis() + NETWORK_RETRY_WINDOW_MS + val networkDeadline = System.currentTimeMillis() + NETWORK_RETRY_WINDOW_MS var networkAttempt = 0 var rateAttempt = 0 + var serverAttempt = 0 requestLoop@ while (true) { val request = Request.Builder() @@ -111,46 +116,79 @@ class GroqEngine( val response = try { http.newCall(request).execute() } catch (error: IOException) { - if (!isTransientNetworkError(error) || System.currentTimeMillis() >= deadline) { + if (!isTransientNetworkError(error) || + System.currentTimeMillis() >= networkDeadline + ) { throw RuntimeException(friendlyNetworkError(error), error) } networkAttempt++ - waitForConnection(networkAttempt, deadline, error) + waitForConnection(networkAttempt, networkDeadline, error) continue@requestLoop } val code = response.code val retryAfterHeader = response.header("retry-after") - val resetHeader = response.header("x-ratelimit-reset-tokens") - val usedHeader = response.header("x-ratelimit-used-tokens") + val resetTokensHeader = response.header("x-ratelimit-reset-tokens") + val limitTokensHeader = response.header("x-ratelimit-limit-tokens") + val remainingTokensHeader = response.header("x-ratelimit-remaining-tokens") + + val resetTokenMs = parseDurationMillis(resetTokensHeader) + rateLimiter?.syncServerTokenWindow( + limit = limitTokensHeader?.toLongOrNull(), + remaining = remainingTokensHeader?.toLongOrNull(), + resetAfterMs = resetTokenMs, + ) val body = try { response.body?.string().orEmpty() } catch (error: IOException) { response.close() - if (!isTransientNetworkError(error) || System.currentTimeMillis() >= deadline) { + if (!isTransientNetworkError(error) || + System.currentTimeMillis() >= networkDeadline + ) { throw RuntimeException(friendlyNetworkError(error), error) } networkAttempt++ - waitForConnection(networkAttempt, deadline, error) + waitForConnection(networkAttempt, networkDeadline, error) continue@requestLoop } finally { response.close() } if (code == 429) { + val message = apiMessage(body) + if (isDailyLimit(message)) { + throw RuntimeException( + "Groq's organization-wide daily limit has been reached. " + + "Sonario saved every completed checkpoint, so resume after Groq resets " + + "the limit or use a Developer plan." + ) + } + rateAttempt++ if (rateAttempt > MAX_RATE_RETRIES) { throw RuntimeException(friendlyError(code, body)) } - val seconds = retrySeconds(retryAfterHeader, resetHeader, body) - var left = seconds.coerceIn(1L, MAX_RATE_WAIT_SECONDS) - while (left > 0) { - onRateWait(left) - delay(1000) - left-- - } - onRateWait(0) + + val waitMs = retryWaitMillis( + retryAfter = retryAfterHeader, + reset = resetTokensHeader, + body = body, + ).coerceIn(1_000L, MAX_MINUTE_RATE_WAIT_MS) + + rateLimiter?.markServerBackoff(waitMs) + waitWithCountdown(waitMs) + continue@requestLoop + } + + if (code in 500..599 && serverAttempt < MAX_SERVER_RETRIES) { + serverAttempt++ + val waitMs = min(30_000L, (1L shl serverAttempt) * 1_000L) + onNetworkStatus( + "Groq had a temporary server error. Retrying in ${waitMs / 1000L}s " + + "(attempt ${serverAttempt + 1})." + ) + delay(waitMs) continue@requestLoop } @@ -161,15 +199,23 @@ class GroqEngine( val parsed = parseCompletion(body) if (parsed.text.isBlank()) { throw RuntimeException( - "Groq returned an empty answer. Check that the selected model is available, " + - "then try again.") + "Groq returned an empty Qwen answer. Please try again." + ) } - val usage = parsed.usageTokens - ?: usedHeader?.toLongOrNull() - ?: estimatedTotal - return parsed.copy(usageTokens = usage) + return parsed.copy(usageTokens = parsed.usageTokens ?: estimatedTotal) + } + } + + private suspend fun waitWithCountdown(waitMs: Long) { + var leftMs = waitMs + while (leftMs > 0L) { + onRateWait(((leftMs + 999L) / 1_000L).coerceAtLeast(1L)) + val step = minOf(leftMs, 1_000L) + delay(step) + leftMs -= step } + onRateWait(0L) } private suspend fun waitForConnection( @@ -177,24 +223,24 @@ class GroqEngine( deadline: Long, error: IOException, ) { - // When Android reports no validated network, wait for it to return instead - // of burning through retries immediately. Otherwise use exponential backoff - // for transient DNS/socket failures while the network still looks connected. if (!hasValidatedInternet()) { while (System.currentTimeMillis() < deadline && !hasValidatedInternet()) { val remainingMinutes = - ((deadline - System.currentTimeMillis()).coerceAtLeast(0) / 60_000L) + 1 + ((deadline - System.currentTimeMillis()).coerceAtLeast(0L) / 60_000L) + 1L onNetworkStatus( "Internet connection lost. Sonario is waiting and will keep retrying " + - "for about $remainingMinutes more minute${if (remainingMinutes == 1L) "" else "s"}.") + "for about $remainingMinutes more minute${if (remainingMinutes == 1L) "" else "s"}." + ) delay(NETWORK_POLL_MS) } if (!hasValidatedInternet()) { throw RuntimeException( - "The phone stayed offline too long, so Sonario could not reach Groq.", error) + "The phone stayed offline too long, so Sonario could not reach Groq.", + error, + ) } onNetworkStatus("Internet is back. Reconnecting to Groq…") - delay(750) + delay(750L) return } @@ -206,8 +252,9 @@ class GroqEngine( } onNetworkStatus( "Groq $reason. Retrying automatically in ${seconds}s " + - "(attempt ${attempt + 1}).") - delay(seconds * 1000L) + "(attempt ${attempt + 1})." + ) + delay(seconds * 1_000L) } private fun hasValidatedInternet(): Boolean { @@ -226,12 +273,17 @@ class GroqEngine( val messages = JSONArray() .put(JSONObject().put("role", "system").put("content", system)) .put(JSONObject().put("role", "user").put("content", user)) + return JSONObject() .put("model", model) .put("messages", messages) .put("temperature", 0.35) + .put("top_p", 0.8) .put("max_tokens", maxTokens) + .put("reasoning_effort", "none") + .put("reasoning_format", "hidden") .put("stream", true) + .put("stream_options", JSONObject().put("include_usage", true)) .toString() } @@ -271,7 +323,8 @@ class GroqEngine( JSONObject(raw) } catch (_: Exception) { throw RuntimeException( - "Groq returned an unreadable response instead of JSON. Please try again.") + "Groq returned an unreadable response instead of JSON. Please try again." + ) } apiError(obj)?.let { throw RuntimeException(it) } val choice = obj.optJSONArray("choices")?.optJSONObject(0) @@ -283,11 +336,11 @@ class GroqEngine( private fun usageFrom(obj: JSONObject): Long? { val direct = obj.optJSONObject("usage")?.optLong("total_tokens", -1L) ?: -1L - if (direct >= 0) return direct + if (direct >= 0L) return direct val groq = obj.optJSONObject("x_groq") ?.optJSONObject("usage") ?.optLong("total_tokens", -1L) ?: -1L - return groq.takeIf { it >= 0 } + return groq.takeIf { it >= 0L } } private fun apiError(obj: JSONObject): String? { @@ -296,26 +349,47 @@ class GroqEngine( return if (message.isBlank()) "Groq returned an API error." else "Groq: $message" } - private fun retrySeconds(retryAfter: String?, reset: String?, body: String): Long { - retryAfter?.trim()?.toDoubleOrNull()?.let { return it.toLong().coerceAtLeast(1) } - parseDurationSeconds(reset)?.let { return it } - val bodyMessage = try { - JSONObject(body).optJSONObject("error")?.optString("message") - } catch (_: Exception) { - null + private fun apiMessage(body: String): String? = try { + JSONObject(body).optJSONObject("error")?.optString("message")?.trim() + } catch (_: Exception) { + null + } + + private fun isDailyLimit(message: String?): Boolean { + val lower = message.orEmpty().lowercase() + return "tokens per day" in lower || "requests per day" in lower || + "daily token" in lower || "daily request" in lower || + Regex("\\btpd\\b").containsMatchIn(lower) || + Regex("\\brpd\\b").containsMatchIn(lower) + } + + private fun retryWaitMillis(retryAfter: String?, reset: String?, body: String): Long { + retryAfter?.trim()?.toDoubleOrNull()?.let { + return (it * 1_000.0).toLong().coerceAtLeast(1_000L) } - parseDurationSeconds(bodyMessage)?.let { return it } - return 60L + parseDurationMillis(reset)?.let { return it } + parseDurationMillis(apiMessage(body))?.let { return it } + return 60_000L } - /** Parses values such as "1m2.5s", "42s", or messages containing them. */ - private fun parseDurationSeconds(value: String?): Long? { + /** Parses durations such as 7.66s, 1m2.5s, 2h3m, or 250ms. */ + private fun parseDurationMillis(value: String?): Long? { if (value.isNullOrBlank()) return null - val match = DURATION_REGEX.find(value.lowercase()) ?: return null - val minutes = match.groups[1]?.value?.toDoubleOrNull() ?: 0.0 - val seconds = match.groups[2]?.value?.toDoubleOrNull() ?: 0.0 - val total = (minutes * 60.0 + seconds).toLong() - return total.coerceAtLeast(1) + var total = 0.0 + var found = false + UNIT_REGEX.findAll(value.lowercase()).forEach { match -> + val amount = match.groupValues[1].toDoubleOrNull() ?: return@forEach + found = true + total += when (match.groupValues[2]) { + "d" -> amount * 86_400_000.0 + "h" -> amount * 3_600_000.0 + "m" -> amount * 60_000.0 + "s" -> amount * 1_000.0 + "ms" -> amount + else -> 0.0 + } + } + return if (found) total.toLong().coerceAtLeast(1L) else null } private fun isTransientNetworkError(error: IOException): Boolean = @@ -337,19 +411,16 @@ class GroqEngine( } private fun friendlyError(code: Int, body: String): String { - val message = try { - JSONObject(body).optJSONObject("error")?.optString("message")?.trim() - } catch (_: Exception) { - null - } + val message = apiMessage(body) return when (code) { - 400 -> "Groq rejected this request" + + 400 -> "Groq rejected this Qwen request" + (if (!message.isNullOrBlank()) ": $message" else ".") 401 -> "Groq rejected the API key. Check it in Settings." - 403 -> "Groq denied this request. Check the API key and model access." - 404 -> "The selected Groq model was not found. Choose a current model in Settings." - 413 -> "This source is too large for one Groq request. Try the normal summary or a shorter source." - 429 -> "Groq's rate limit is still active after repeated waits. Try again later or use a shorter source" + + 403 -> "Groq denied this request. Check the API key and Qwen access." + 404 -> "Qwen 3.6 27B is not available for this Groq account or region." + 413 -> "This request is too large for Groq. Sonario saved the completed checkpoints." + 429 -> "Groq's minute rate limit is still active after repeated waits. " + + "Your completed checkpoints are saved; try Resume shortly" + (if (!message.isNullOrBlank()) ": $message" else ".") in 500..599 -> "Groq had a server error after automatic retries. Try again shortly." else -> "Groq request failed ($code)" + @@ -358,10 +429,11 @@ class GroqEngine( } companion object { - private const val NETWORK_RETRY_WINDOW_MS = 10L * 60L * 1000L + private const val NETWORK_RETRY_WINDOW_MS = 10L * 60L * 1_000L private const val NETWORK_POLL_MS = 2_000L private const val MAX_RATE_RETRIES = 8 - private const val MAX_RATE_WAIT_SECONDS = 10L * 60L - private val DURATION_REGEX = Regex("(?:(\\d+(?:\\.\\d+)?)m)?\\s*(\\d+(?:\\.\\d+)?)s") + private const val MAX_SERVER_RETRIES = 3 + private const val MAX_MINUTE_RATE_WAIT_MS = 10L * 60L * 1_000L + private val UNIT_REGEX = Regex("(\\d+(?:\\.\\d+)?)\\s*(ms|d|h|m|s)\\b") } } diff --git a/app/src/main/java/ai/sonario/app/llm/RateLimiter.kt b/app/src/main/java/ai/sonario/app/llm/RateLimiter.kt index 8caaa0d..703bd4b 100644 --- a/app/src/main/java/ai/sonario/app/llm/RateLimiter.kt +++ b/app/src/main/java/ai/sonario/app/llm/RateLimiter.kt @@ -2,165 +2,237 @@ package ai.sonario.app.llm import android.content.Context import kotlinx.coroutines.delay +import kotlin.math.ceil +import kotlin.math.max /** - * Paces Groq requests to stay within the free-tier limits: - * - ~30,000 tokens per minute (TPM) - * - ~500,000 tokens per day (TPD) + * App-side pacing for Groq's free Qwen 3.6 27B limits. * - * Before each request we estimate its token cost and, if sending it now would - * exceed the per-minute budget, we wait until the rolling minute window has room. - * Actual usage is reconciled from Groq's response headers when present. The daily - * total is persisted so it survives app restarts within the same day. - * - * These limits are conservative defaults; Groq may grant a given model more. They - * are safe lower bounds so we err toward not getting 429'd. + * Groq currently publishes 30 RPM, 8,000 TPM and 200,000 TPD for + * qwen/qwen3.6-27b. Sonario keeps a little headroom, queues requests instead of + * repeatedly receiving 429 responses, and also honors the token-window headers + * returned by Groq. Limits are organization-wide, so activity outside Sonario + * can still reduce what is available. */ class RateLimiter(context: Context) { private val prefs = context.applicationContext - .getSharedPreferences("groq_rate", Context.MODE_PRIVATE) + .getSharedPreferences("groq_rate_qwen36", Context.MODE_PRIVATE) - // Conservative free-tier caps. Leave headroom below the real ceilings. - private val tpmLimit = 28_000L // under the ~30k/min cap - private val tpdLimit = 480_000L // under the ~500k/day cap + private val tokenWindow = ArrayDeque>() + private val requestWindow = ArrayDeque() - // Rolling per-minute window: timestamps (ms) paired with tokens spent. - private val window = ArrayDeque>() + @Volatile private var serverRemainingTokens: Long? = null + @Volatile private var serverTokenResetAtMs: Long = 0L + @Volatile private var serverBlockedUntilMs: Long = 0L data class DailyUsage(val used: Long, val limit: Long) { val remaining: Long get() = (limit - used).coerceAtLeast(0) } - // ── daily tracking (persisted) ───────────────────────────────────────────── - - private fun today(): String { - // yyyyDDD-ish key from epoch day; no formatting deps needed. - val epochDay = System.currentTimeMillis() / 86_400_000L - return epochDay.toString() + data class Estimate( + val inputTokens: Long, + val totalTokens: Long, + val dailyRemaining: Long, + val dailyLimit: Long, + val exceedsDaily: Boolean, + val etaSeconds: Long, + ) { + val percentOfRemaining: Int = + if (dailyRemaining > 0) + ((totalTokens.toDouble() / dailyRemaining) * 100).toInt().coerceIn(0, 999) + else 100 } + private fun today(): String = + (System.currentTimeMillis() / 86_400_000L).toString() + private fun dailyUsed(): Long { - val day = prefs.getString("day", null) - if (day != today()) return 0 - return prefs.getLong("used", 0) + if (prefs.getString("day", null) != today()) return 0L + return prefs.getLong("used", 0L) } private fun addDaily(tokens: Long) { - val used = if (prefs.getString("day", null) == today()) - prefs.getLong("used", 0) else 0 + val used = if (prefs.getString("day", null) == today()) { + prefs.getLong("used", 0L) + } else { + 0L + } prefs.edit() .putString("day", today()) - .putLong("used", used + tokens) + .putLong("used", used + tokens.coerceAtLeast(0L)) .apply() } - fun dailyUsage(): DailyUsage = DailyUsage(dailyUsed(), tpdLimit) + fun dailyUsage(): DailyUsage = DailyUsage(dailyUsed(), DAILY_WORKING_LIMIT) - /** - * Reset the daily counter. Call when the API key changes: a different key has - * its own separate daily budget on Groq's side, so the old key's tally no - * longer applies. - */ fun resetDaily() { - prefs.edit().putString("day", today()).putLong("used", 0).apply() - window.clear() + prefs.edit().putString("day", today()).putLong("used", 0L).apply() + synchronized(this) { + tokenWindow.clear() + requestWindow.clear() + serverRemainingTokens = null + serverTokenResetAtMs = 0L + serverBlockedUntilMs = 0L + } } /** - * A pre-flight estimate for summarizing [sourceText]. Accounts for the fact - * that map-reduce sends the text once for condensing plus a smaller combine - * pass, so total tokens are a bit more than the raw input. + * Estimate the whole Sonario job, not just its first request. Long cloud jobs + * normally read the source once for notes and again for the detailed view. */ - data class Estimate( - val inputTokens: Long, - val totalTokens: Long, - val dailyRemaining: Long, - val dailyLimit: Long, - val exceedsDaily: Boolean, - val etaSeconds: Long, - ) { - val percentOfRemaining: Int = - if (dailyRemaining > 0) - ((totalTokens.toDouble() / dailyRemaining) * 100).toInt().coerceIn(0, 999) - else 100 - } - fun estimate(sourceText: String): Estimate { val input = estimateTokens(sourceText) - // Map-reduce overhead: prompts on each chunk + a combine pass + output. - // Empirically ~1.3x the input plus generated summary tokens. - val total = (input * 1.3).toLong() + 1200 + val total = (input * 2.4).toLong() + 5_000L val remaining = dailyUsage().remaining - // ETA is dominated by per-minute pacing: how many minute-windows the - // total spans at tpmLimit, plus a little for actual generation. - val minutes = (total.toDouble() / tpmLimit) - val etaSec = (minutes * 60).toLong().coerceAtLeast(2) + 4 + val minuteWindows = ceil(total.toDouble() / TOKEN_WORKING_LIMIT).toLong() + val etaSec = (minuteWindows * 60L).coerceAtLeast(4L) return Estimate( inputTokens = input, totalTokens = total, dailyRemaining = remaining, - dailyLimit = tpdLimit, + dailyLimit = DAILY_WORKING_LIMIT, exceedsDaily = total > remaining, etaSeconds = etaSec, ) } - /** True if this many tokens would blow the daily cap (can't be waited out). */ - fun wouldExceedDaily(tokens: Long): Boolean = dailyUsed() + tokens > tpdLimit + fun wouldExceedDaily(tokens: Long): Boolean = + dailyUsed() + tokens > DAILY_WORKING_LIMIT + + /** + * Wait until both the local rolling windows and Groq's last reported server + * window can accept this request. The callback receives a live countdown. + */ + suspend fun awaitSlot(tokens: Long, onWaiting: (Long) -> Unit) { + if (tokens > MAX_REQUEST_TOKENS) { + throw IllegalStateException( + "This Groq request is too large for Qwen's 8K-token-per-minute free limit. " + + "Sonario should have split it automatically; please report this source." + ) + } + if (wouldExceedDaily(tokens)) { + throw IllegalStateException( + "Sonario's conservative daily Groq budget has been reached. " + + "Your completed checkpoints are saved; continue after the daily limit resets " + + "or use a Groq Developer plan." + ) + } - // ── per-minute pacing ────────────────────────────────────────────────────── + while (true) { + val waitMs = synchronized(this) { waitMillisForLocked(tokens) } + if (waitMs <= 0L) break + onWaiting(ceil(waitMs / 1000.0).toLong().coerceAtLeast(1L)) + delay(minOf(waitMs, 1_000L)) + } + onWaiting(0L) - private fun trimWindow(now: Long) { - while (window.isNotEmpty() && now - window.first().first >= 60_000L) { - window.removeFirst() + synchronized(this) { + val now = System.currentTimeMillis() + trimLocked(now) + requestWindow.addLast(now) } } - private fun tokensInWindow(now: Long): Long { - trimWindow(now) - return window.sumOf { it.second } + /** Record actual tokens after a successful response. */ + fun record(tokens: Long) { + val safe = tokens.coerceAtLeast(0L) + synchronized(this) { + val now = System.currentTimeMillis() + trimLocked(now) + tokenWindow.addLast(now to safe) + } + addDaily(safe) } /** - * Milliseconds to wait before a request costing [tokens] can be sent without - * exceeding the per-minute budget. 0 if it can go now. + * Synchronize with Groq's x-ratelimit-* token headers. These headers describe + * the provider's real organization-wide minute window and are more reliable + * than Sonario's local estimate when the same organization is used elsewhere. */ - fun waitMillisFor(tokens: Long): Long { - val now = System.currentTimeMillis() - val inWindow = tokensInWindow(now) - if (inWindow + tokens <= tpmLimit) return 0 - // Wait until the oldest entries age out enough to make room. - var needed = inWindow + tokens - tpmLimit - var waitUntil = now - for ((ts, tok) in window) { - needed -= tok - if (needed <= 0) { waitUntil = ts + 60_000L; break } + fun syncServerTokenWindow(limit: Long?, remaining: Long?, resetAfterMs: Long?) { + if (remaining == null || resetAfterMs == null || resetAfterMs <= 0L) return + synchronized(this) { + // Ignore obviously unrelated/invalid values, but accept account-specific + // limits rather than assuming every organization has the base free tier. + if (limit != null && limit <= 0L) return + serverRemainingTokens = remaining.coerceAtLeast(0L) + serverTokenResetAtMs = System.currentTimeMillis() + resetAfterMs } - return (waitUntil - now).coerceAtLeast(0) } - /** Suspend until a request of [tokens] can proceed; reports wait seconds. */ - suspend fun awaitSlot(tokens: Long, onWaiting: (Long) -> Unit) { - var wait = waitMillisFor(tokens) - while (wait > 0) { - onWaiting((wait + 999) / 1000) // ceil to seconds, for the UI - val step = minOf(wait, 1000L) - delay(step) - wait = waitMillisFor(tokens) + fun markServerBackoff(waitMs: Long) { + if (waitMs <= 0L) return + synchronized(this) { + serverBlockedUntilMs = max( + serverBlockedUntilMs, + System.currentTimeMillis() + waitMs, + ) } } - /** Record tokens actually spent (estimate up front, reconcile from headers). */ - fun record(tokens: Long) { + private fun waitMillisForLocked(tokens: Long): Long { val now = System.currentTimeMillis() - trimWindow(now) - window.addLast(now to tokens) - addDaily(tokens) + trimLocked(now) + + var waitMs = 0L + + val localTokens = tokenWindow.sumOf { it.second } + if (localTokens + tokens > TOKEN_WORKING_LIMIT && tokenWindow.isNotEmpty()) { + var needed = localTokens + tokens - TOKEN_WORKING_LIMIT + for ((timestamp, spent) in tokenWindow) { + needed -= spent + if (needed <= 0L) { + waitMs = max(waitMs, timestamp + WINDOW_MS - now) + break + } + } + } + + if (requestWindow.size >= REQUEST_WORKING_LIMIT && requestWindow.isNotEmpty()) { + waitMs = max(waitMs, requestWindow.first() + WINDOW_MS - now) + } + + if (serverBlockedUntilMs > now) { + waitMs = max(waitMs, serverBlockedUntilMs - now) + } + + if (serverTokenResetAtMs <= now) { + serverRemainingTokens = null + serverTokenResetAtMs = 0L + } else { + val remaining = serverRemainingTokens + if (remaining != null && tokens > remaining) { + waitMs = max(waitMs, serverTokenResetAtMs - now) + } + } + + return waitMs.coerceAtLeast(0L) + } + + private fun trimLocked(now: Long) { + while (tokenWindow.isNotEmpty() && now - tokenWindow.first().first >= WINDOW_MS) { + tokenWindow.removeFirst() + } + while (requestWindow.isNotEmpty() && now - requestWindow.first() >= WINDOW_MS) { + requestWindow.removeFirst() + } } companion object { - /** Rough token estimate: ~4 chars per token, plus a small overhead. */ - fun estimateTokens(text: String): Long = (text.length / 4L) + 16 + const val PUBLISHED_TPM = 8_000L + const val PUBLISHED_TPD = 200_000L + const val PUBLISHED_RPM = 30 + + // Small buffers account for prompt/token-estimation error and other use in + // the same Groq organization. + const val MAX_REQUEST_TOKENS = 7_400L + private const val TOKEN_WORKING_LIMIT = 7_600L + private const val DAILY_WORKING_LIMIT = 195_000L + private const val REQUEST_WORKING_LIMIT = 28 + private const val WINDOW_MS = 60_000L + + /** Rough estimate: about four UTF-16 characters per model token. */ + fun estimateTokens(text: String): Long = (text.length / 4L) + 16L } } diff --git a/app/src/main/java/ai/sonario/app/summarize/SummarizeEngine.kt b/app/src/main/java/ai/sonario/app/summarize/SummarizeEngine.kt index 081d9e5..36dd117 100644 --- a/app/src/main/java/ai/sonario/app/summarize/SummarizeEngine.kt +++ b/app/src/main/java/ai/sonario/app/summarize/SummarizeEngine.kt @@ -16,25 +16,25 @@ import kotlinx.coroutines.flow.collect * generated all views up front so the UI could toggle instantly. * * The engine is an InferenceEngine, so this works with the on-device model or - * the Groq cloud engine. Chunking adapts: on-device models have a small (~4k - * token) context so chunks are small and the total work is hard-capped; the - * cloud path (Llama 4 Scout, 128k context) uses much larger chunks and rarely - * needs to chunk at all. + * the Groq cloud engine. Cloud chunks are intentionally much smaller than + * Qwen's 131k context window because Groq's free tier limits the organization + * to 8k tokens per minute. Each individual request must fit comfortably inside + * that minute budget before the rate limiter queues the next request. */ class SummarizeEngine( private val engine: InferenceEngine, private val bigContext: Boolean = false, ) { - // On-device: ~2800 chars/chunk (~800 tokens). Cloud: much larger, since a - // 128k-context model swallows most sources in one or a few passes. - private val chunkChars = if (bigContext) 40000 else 2800 - private val singlePassLimit = if (bigContext) 120000 else 3200 + // Qwen cloud calls stay below the 8K TPM ceiling even after prompt and output + // tokens are included. On-device chunks remain small for CPU performance. + private val chunkChars = if (bigContext) 14000 else 2800 + private val singlePassLimit = if (bigContext) 16000 else 3200 - // Work cap. On-device this bounds runtime (CPU is slow). Cloud can afford - // more passes, but we still cap so a giant book stays within rate limits. - private val maxChunks = if (bigContext) 40 else 20 - private val maxChunkChars = if (bigContext) 48000 else 6000 + // Fourteen cloud chunks keep a full Normal + Detailed run within roughly one + // free-tier daily allowance while still covering long videos/documents. + private val maxChunks = if (bigContext) 14 else 20 + private val maxChunkChars = if (bigContext) 16000 else 6000 data class Progress( val phase: String, // "fetching" | "chunking" | "condensing" | "synthesizing" | "deriving" | "done" @@ -163,7 +163,10 @@ class SummarizeEngine( val ch = chapters[i] _progress.value = Progress("chapters", i + 1, chapters.size) val summary = runCatching { - Cleaner.clean(streamCollect(Prompts.CHAPTER, source(ch.text), maxTokens = 300)) + val boundedChapter = ch.text.take(singlePassLimit) + Cleaner.clean( + streamCollect(Prompts.CHAPTER, source(boundedChapter), maxTokens = 300) + ) }.getOrDefault("") val section = buildString { append("## ${ch.title}\n\n") @@ -186,14 +189,13 @@ class SummarizeEngine( /** * Answer a question grounded in the source text, with inline [n] citations. * The source is chunked into numbered excerpts so the model can cite them; - * for very long sources we cap how much is sent (cloud can take a lot). + * for very long sources we select relevant passages from across the source. */ suspend fun answer(question: String, sourceText: String): String { _progress.value = Progress("answering", 0, 1) - // Do not blindly send only the beginning of a long video/book. Select the - // passages most relevant to the question from across the entire source, - // while keeping the prompt comfortably inside the model context window. + // Keep the Qwen request under the same free-tier TPM budget used by the + // summary pipeline while still sampling relevant material across the source. val excerpts = selectRelevantExcerpts(question, sourceText) val numbered = excerpts.mapIndexed { i, excerpt -> "[${i + 1}] $excerpt" @@ -204,7 +206,7 @@ class SummarizeEngine( streamCollect( Prompts.ASK, user, - maxTokens = if (bigContext) 1200 else 700, + maxTokens = if (bigContext) 1000 else 700, ) ) _progress.value = Progress("done") @@ -215,8 +217,8 @@ class SummarizeEngine( } private fun selectRelevantExcerpts(question: String, sourceText: String): List { - val excerptSize = if (bigContext) 1800 else 1000 - val maxExcerpts = if (bigContext) 30 else 6 + val excerptSize = if (bigContext) 1400 else 1000 + val maxExcerpts = if (bigContext) 8 else 6 val all = chunkText(sourceText, excerptSize) if (all.isEmpty()) return listOf(sourceText.take(excerptSize)) if (all.size <= maxExcerpts) return all @@ -279,25 +281,31 @@ class SummarizeEngine( // ── stages ────────────────────────────────────────────────────────────────── /** pipeline._final_combine, simplified: one-shot, else hierarchical batches. */ private suspend fun finalCombine(joined: String): String { - // 1) one-shot - runCatching { - return streamCollect(Prompts.REDUCE, "Section notes, in order:\n\n$joined") + // A one-shot combine is only safe when the notes themselves fit the + // per-request free-tier token budget. + if (joined.length <= singlePassLimit) { + runCatching { + return streamCollect(Prompts.REDUCE, "Section notes, in order:\n\n$joined") + } } - // 2) hierarchical: batch the note-blocks, condense each, then combine + + // Hierarchical: batch the note-blocks, condense each, then combine. val blocks = joined.split("\n\n").filter { it.isNotBlank() } val partials = ArrayList() var i = 0 while (i < blocks.size) { val batch = blocks.subList(i, minOf(i + 5, blocks.size)).joinToString("\n\n") - partials.add(runCatching { streamCollect(Prompts.CHUNK, batch) } - .getOrDefault(batch.take(1500))) + partials.add( + runCatching { streamCollect(Prompts.CHUNK, batch) } + .getOrDefault(batch.take(1500)) + ) i += 5 } val small = partials.joinToString("\n\n").take(singlePassLimit) runCatching { return streamCollect(Prompts.REDUCE, "Section notes, in order:\n\n$small") } - // 3) pure-local fallback: never throw away a finished run + // Pure-local fallback: never throw away a finished run. return "**Combined from section notes.**\n\n$small" } @@ -307,11 +315,10 @@ class SummarizeEngine( existingParts: List = emptyList(), onParts: suspend (List) -> Unit = {}, ): String { - // Detailed wants real length. Give the cloud engine a large output budget - // (~2 full pages); on-device stays modest so it doesn't run for ages. - val onePassCap = if (bigContext) 4000 else 1600 - val chunkCap = if (bigContext) 3000 else 1200 - if (src.length <= singlePassLimit * 2) { + val onePassCap = if (bigContext) 2200 else 1600 + val chunkCap = if (bigContext) 1600 else 1200 + val onePassSourceLimit = if (bigContext) singlePassLimit else singlePassLimit * 2 + if (src.length <= onePassSourceLimit) { return streamCollect(Prompts.DETAILED, source(src), maxTokens = onePassCap) } val chunks = chunkTextCapped(src) @@ -340,8 +347,8 @@ class SummarizeEngine( // Bounded chunking: pick a chunk size so the total number of chunks stays // under maxChunks, growing chunk size as needed up to maxChunkChars. If the // source is so large that even maxChunks * maxChunkChars can't hold it, we - // process only that leading portion so a single job stays time-bounded on - // CPU. This is what prevents the "condensing section 0 of 972" runaway. + // process only that leading portion so a single job stays within its daily + // work budget. private fun chunkTextCapped(text: String): List { val cap = maxChunks * maxChunkChars val material = if (text.length > cap) text.substring(0, cap) else text @@ -359,12 +366,16 @@ class SummarizeEngine( var cur = StringBuilder() for (line in text.split("\n")) { if (line.length > size) { - if (cur.isNotEmpty()) { chunks.add(cur.toString()); cur = StringBuilder() } + if (cur.isNotEmpty()) { + chunks.add(cur.toString()) + cur = StringBuilder() + } chunks.addAll(hardSplit(line, size)) continue } if (cur.length + line.length + 1 > size && cur.isNotEmpty()) { - chunks.add(cur.toString()); cur = StringBuilder(line) + chunks.add(cur.toString()) + cur = StringBuilder(line) } else { if (cur.isEmpty()) cur.append(line) else cur.append("\n").append(line) } @@ -378,8 +389,11 @@ class SummarizeEngine( val out = ArrayList() while (s.length > size) { val window = s.substring(0, size) - var cut = maxOf(window.lastIndexOf(". "), window.lastIndexOf("? "), - window.lastIndexOf("! ")) + var cut = maxOf( + window.lastIndexOf(". "), + window.lastIndexOf("? "), + window.lastIndexOf("! "), + ) if (cut < size * 0.5) cut = window.lastIndexOf(' ') if (cut <= 0) cut = size out.add(s.substring(0, cut + 1).trim()) @@ -388,6 +402,7 @@ class SummarizeEngine( if (s.isNotEmpty()) out.add(s) return out } + companion object { private val WORD_REGEX = Regex("[\\p{L}\\p{N}']+") private val ASK_STOP_WORDS = setOf( @@ -395,8 +410,7 @@ class SummarizeEngine( "where", "which", "who", "why", "how", "does", "did", "was", "were", "are", "is", "can", "could", "would", "should", "about", "into", "than", "then", "they", "them", "their", "there", "have", "has", "had", "you", - "your", "its", "but", "not", "all", "any", "some", "more", "most" + "your", "its", "but", "not", "all", "any", "some", "more", "most", ) } - } diff --git a/app/src/main/java/ai/sonario/app/ui/SettingsScreen.kt b/app/src/main/java/ai/sonario/app/ui/SettingsScreen.kt index cdf4c83..ace8d89 100644 --- a/app/src/main/java/ai/sonario/app/ui/SettingsScreen.kt +++ b/app/src/main/java/ai/sonario/app/ui/SettingsScreen.kt @@ -18,8 +18,9 @@ import androidx.compose.ui.unit.dp import ai.sonario.app.data.EngineChoice /** - * Settings: choose the engine and, for the Groq cloud engine, paste an API key - * and set the model string. The key is stored locally on the device only. + * Settings: choose the engine and, for Groq cloud, save an API key. Sonario's + * cloud model is fixed to Qwen 3.6 27B so saved sessions cannot restore a retired + * or incompatible model ID. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -28,7 +29,6 @@ fun SettingsScreen(vm: SummaryViewModel, onBack: () -> Unit) { val scroll = rememberScrollState() var keyInput by remember { mutableStateOf("") } - var modelInput by remember { mutableStateOf(ui.groqModel) } Scaffold( containerColor = SonarioColors.Deep, @@ -37,33 +37,42 @@ fun SettingsScreen(vm: SummaryViewModel, onBack: () -> Unit) { title = { Text("Settings", color = SonarioColors.Ink) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", - tint = SonarioColors.InkSoft) + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + "Back", + tint = SonarioColors.InkSoft, + ) } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = SonarioColors.Deep), + containerColor = SonarioColors.Deep, + ), ) - } + }, ) { pad -> Column( - Modifier.padding(pad).verticalScroll(scroll).padding(16.dp) + Modifier + .padding(pad) + .verticalScroll(scroll) + .padding(16.dp), ) { - // Engine choice - Text("Where the AI runs", color = SonarioColors.InkSoft, - style = MaterialTheme.typography.labelLarge) + Text( + "Where the AI runs", + color = SonarioColors.InkSoft, + style = MaterialTheme.typography.labelLarge, + ) Spacer(Modifier.height(10.dp)) EngineOption( title = "On-device", subtitle = "Runs the model on your phone. Private, but slow " + - "(CPU only). Needs a downloaded model.", + "(CPU only). Needs a downloaded model.", selected = ui.engineChoice == EngineChoice.ON_DEVICE, onClick = { vm.setEngine(EngineChoice.ON_DEVICE) }, ) EngineOption( title = "Groq (cloud)", - subtitle = "Fast. Sends your text to Groq's servers to summarize. " + - "Needs a free API key below.", + subtitle = "Fast. Sends your text to Groq's servers and uses " + + "Qwen 3.6 27B. Needs a Groq API key below.", selected = ui.engineChoice == EngineChoice.GROQ, onClick = { vm.setEngine(EngineChoice.GROQ) }, ) @@ -72,39 +81,46 @@ fun SettingsScreen(vm: SummaryViewModel, onBack: () -> Unit) { HorizontalDivider(color = SonarioColors.RuleSoft) Spacer(Modifier.height(20.dp)) - // Groq settings - Text("Groq cloud", color = SonarioColors.InkSoft, - style = MaterialTheme.typography.labelLarge) + Text( + "Groq cloud", + color = SonarioColors.InkSoft, + style = MaterialTheme.typography.labelLarge, + ) Spacer(Modifier.height(6.dp)) Text( - "Get a free API key at console.groq.com (no credit card). Create a " + - "key, then paste it here. Your key is stored only on this device " + - "and is sent solely to Groq when you summarize.", + "Get an API key at console.groq.com, then paste it here. Your key " + + "is stored only on this device and is sent solely to Groq when " + + "you summarize.", color = SonarioColors.Muted, - style = MaterialTheme.typography.bodyMedium) + style = MaterialTheme.typography.bodyMedium, + ) Spacer(Modifier.height(12.dp)) if (ui.groqKeySet) { - Text("Key saved: ${vm.currentGroqKeyMasked()}", + Text( + "Key saved: ${vm.currentGroqKeyMasked()}", color = SonarioColors.Green, - style = MaterialTheme.typography.labelLarge) + style = MaterialTheme.typography.labelLarge, + ) Spacer(Modifier.height(6.dp)) val (used, limit) = vm.groqDailyUsage() val remaining = (limit - used).coerceAtLeast(0) val pct = if (limit > 0) (remaining * 100 / limit).toInt() else 0 Text( - "Daily budget: $pct% remaining " + - "(~${fmtK(remaining)} of ${fmtK(limit)} tokens left today)", + "Sonario budget: $pct% remaining " + + "(~${fmtK(remaining)} of ${fmtK(limit)} tokens left today)", color = if (pct < 15) SonarioColors.Teal else SonarioColors.Muted, - style = MaterialTheme.typography.bodyMedium) + style = MaterialTheme.typography.bodyMedium, + ) Text( - "Counts usage through this app only; resets daily. Groq's free " + - "tier is about ${fmtK(limit)} tokens/day.", + "This is Sonario's conservative local counter. Groq applies " + + "limits across the entire organization, including use outside this app.", color = SonarioColors.Muted, - style = MaterialTheme.typography.bodySmall) + style = MaterialTheme.typography.bodySmall, + ) Spacer(Modifier.height(4.dp)) TextButton(onClick = { vm.resetDailyBudget() }) { - Text("Reset counter", color = SonarioColors.Green) + Text("Reset local counter", color = SonarioColors.Green) } Spacer(Modifier.height(8.dp)) } @@ -131,38 +147,57 @@ fun SettingsScreen(vm: SummaryViewModel, onBack: () -> Unit) { enabled = keyInput.isNotBlank(), colors = ButtonDefaults.buttonColors( containerColor = SonarioColors.Green, - contentColor = SonarioColors.Abyss), + contentColor = SonarioColors.Abyss, + ), ) { Text("Save key") } Spacer(Modifier.height(20.dp)) - OutlinedTextField( - value = modelInput, - onValueChange = { modelInput = it }, - label = { Text("Groq model") }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + Surface( + color = SonarioColors.Panel, + shape = RoundedCornerShape(14.dp), modifier = Modifier.fillMaxWidth(), - colors = sonarioFieldColors(), - ) - Spacer(Modifier.height(4.dp)) - Text( - "Model names change over time. Default is Llama 4 Scout. If Groq " + - "retires it, set another from console.groq.com/docs/models.", - color = SonarioColors.Muted, - style = MaterialTheme.typography.bodyMedium) - Spacer(Modifier.height(8.dp)) - OutlinedButton( - onClick = { vm.setGroqModel(modelInput) }, - colors = ButtonDefaults.outlinedButtonColors( - contentColor = SonarioColors.InkSoft), - ) { Text("Save model") } + ) { + Column(Modifier.padding(16.dp)) { + Text( + "Cloud model", + color = SonarioColors.Muted, + style = MaterialTheme.typography.labelMedium, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Qwen 3.6 27B", + color = SonarioColors.Ink, + fontWeight = FontWeight.SemiBold, + ) + Text( + "qwen/qwen3.6-27b", + color = SonarioColors.Green, + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(10.dp)) + Text( + "Free-tier baseline: 8K tokens/minute and 200K tokens/day. " + + "Sonario splits large sources, waits between calls, follows " + + "Groq's live reset headers, and saves a checkpoint after each call.", + color = SonarioColors.Muted, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Groq limits are organization-wide. Extra keys in the same " + + "organization share the same quota and do not increase it.", + color = SonarioColors.Muted, + style = MaterialTheme.typography.bodySmall, + ) + } + } Spacer(Modifier.height(24.dp)) HorizontalDivider(color = SonarioColors.RuleSoft) Spacer(Modifier.height(12.dp)) Text( - "Sonario 1.3.3 • keyboard-safe Ask field", + "Sonario 1.4.0 • Qwen 3.6 cloud and rate-aware queueing", color = SonarioColors.Muted, style = MaterialTheme.typography.bodySmall, ) @@ -181,28 +216,40 @@ private fun EngineOption( Surface( color = if (selected) SonarioColors.Panel2 else SonarioColors.Panel, shape = RoundedCornerShape(14.dp), - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), onClick = onClick, ) { - Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Row( + Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { RadioButton( selected = selected, onClick = onClick, colors = RadioButtonDefaults.colors( selectedColor = SonarioColors.Green, - unselectedColor = SonarioColors.Muted), + unselectedColor = SonarioColors.Muted, + ), ) Spacer(Modifier.width(8.dp)) Column { - Text(title, color = SonarioColors.Ink, fontWeight = FontWeight.SemiBold) - Text(subtitle, color = SonarioColors.Muted, - style = MaterialTheme.typography.bodyMedium) + Text( + title, + color = SonarioColors.Ink, + fontWeight = FontWeight.SemiBold, + ) + Text( + subtitle, + color = SonarioColors.Muted, + style = MaterialTheme.typography.bodyMedium, + ) } } } } - /** Compact token count: 480000 -> "480K", 1200000 -> "1.2M". */ private fun fmtK(n: Long): String = when { n >= 1_000_000 -> String.format("%.1fM", n / 1_000_000.0)