Summary
On the Standard / Standard Full build, AI Translation fails with every cloud provider except Gemini, while AI Proofreading works perfectly with the same provider, endpoint and model. Translation via the local GGUF (llama.cpp) model also works on the Offline flavor.
Provider used for testing: OpenAI-compatible custom endpoint (verified separately as functional — the exact same provider/model pair succeeds at proofreading).
Root cause (from v4.1.7 source, master ≈ v4.1.7)
In app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt, translateAsync() gates all built-in-AI fallback branches on:
// ProofreadHelper.kt, line ~386
val hasAiConfigured = !service.getApiKey().isNullOrBlank()
but ProofreadService.getApiKey() reads only the Gemini key:
// ProofreadService.kt, line ~79
fun getApiKey(): String? = securePrefs.getString(KEY_API_KEY, null)?.takeIf { it.isNotBlank() }
// ProofreadService.kt, line ~645
private const val KEY_API_KEY = "gemini_api_key"
So when the configured provider is AIProvider.OPENAI (custom OpenAI-compatible endpoint) or AIProvider.GROQ, getApiKey() returns null even though the provider is fully configured, hasAiConfigured is false, and every fallback-to-AI branch short-circuits before any network request is made:
// ProofreadHelper.kt, lines ~414 / ~436 / ~449 / ~462
if (isOfflineOnly || !hasAiConfigured) {
...
Result.failure(Exception("Translation plugin not available"))
} else {
... service.translate(text) // never reached for OPENAI/GROQ
}
The failure surfaces to the user as the misleading toast "Offline model not downloaded" (R.string.translation_model_not_downloaded).
Why the other paths are unaffected
- Proofreading (
proofreadAsync()): no such gate; the OPENAI/GROQ path resolves the correct per-provider token via getHuggingFaceToken() (stored under huggingface_token) inside huggingFaceTranslate() — which is why proofreading works.
- GGUF translation (Offline flavor):
translateAsync() checks !service.getModelPath().isNullOrBlank() (the local model file) instead — which is why GGUF translation works.
The gate appears to be a leftover from when Gemini was the only cloud provider.
Steps to reproduce
- Settings → AI Integration → set provider to OpenAI-compatible (custom endpoint; e.g. a LiteLLM/LM Studio/llama.cpp server) and complete both proofread and translation model selection.
- Verify AI Proofread works with the endpoint.
- Set Translation mode to Cloud/Local AI, enter text in any field, tap the Translate toolbar key.
- → Toast: "Offline model not downloaded" / result failure — despite a working, fully configured cloud provider and no offline models involved.
- Repeat with provider Gemini + valid Gemini key → translation works.
Expected behavior
The AI-fallback availability check in translateAsync() should test the key/token of the active provider (e.g. getHuggingFaceToken() for OPENAI, getGroqToken() for GROQ), the same way the proofreading path does — not hardcode the Gemini key.
Suggested one-line direction:
val hasAiConfigured = when (service.getProvider()) {
AIProvider.GEMINI -> !service.getApiKey().isNullOrBlank()
AIProvider.GROQ -> !service.getGroqToken().isNullOrBlank()
AIProvider.OPENAI -> !service.getHuggingFaceToken().isNullOrBlank()
}
Environment
- App version: v4.1.7 (standardfull APK)
- Device: Google Pixel 10a, Android 16
- Provider: OpenAI-compatible custom endpoint (OpenAI
/v1/chat/completions format)
- Verified affected:
master HEAD (≈ v4.1.7)
Workaround (for affected users)
Paste any dummy value (e.g. x) into the Gemini API key field while keeping the provider on OpenAI-compatible — this flips hasAiConfigured to true and translation then correctly falls through to the active OpenAI-compatible provider. Gemini itself is never contacted since translate() dispatches on the selected provider.
Summary
On the Standard / Standard Full build, AI Translation fails with every cloud provider except Gemini, while AI Proofreading works perfectly with the same provider, endpoint and model. Translation via the local GGUF (llama.cpp) model also works on the Offline flavor.
Provider used for testing: OpenAI-compatible custom endpoint (verified separately as functional — the exact same provider/model pair succeeds at proofreading).
Root cause (from v4.1.7 source,
master≈v4.1.7)In
app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt,translateAsync()gates all built-in-AI fallback branches on:but
ProofreadService.getApiKey()reads only the Gemini key:So when the configured provider is
AIProvider.OPENAI(custom OpenAI-compatible endpoint) orAIProvider.GROQ,getApiKey()returns null even though the provider is fully configured,hasAiConfiguredisfalse, and every fallback-to-AI branch short-circuits before any network request is made:The failure surfaces to the user as the misleading toast "Offline model not downloaded" (
R.string.translation_model_not_downloaded).Why the other paths are unaffected
proofreadAsync()): no such gate; the OPENAI/GROQ path resolves the correct per-provider token viagetHuggingFaceToken()(stored underhuggingface_token) insidehuggingFaceTranslate()— which is why proofreading works.translateAsync()checks!service.getModelPath().isNullOrBlank()(the local model file) instead — which is why GGUF translation works.The gate appears to be a leftover from when Gemini was the only cloud provider.
Steps to reproduce
Expected behavior
The AI-fallback availability check in
translateAsync()should test the key/token of the active provider (e.g.getHuggingFaceToken()for OPENAI,getGroqToken()for GROQ), the same way the proofreading path does — not hardcode the Gemini key.Suggested one-line direction:
Environment
/v1/chat/completionsformat)masterHEAD (≈v4.1.7)Workaround (for affected users)
Paste any dummy value (e.g.
x) into the Gemini API key field while keeping the provider on OpenAI-compatible — this flipshasAiConfiguredtotrueand translation then correctly falls through to the active OpenAI-compatible provider. Gemini itself is never contacted sincetranslate()dispatches on the selected provider.