Provider Guide¶
How to use built-in providers and implement cloud providers.
HalogenLlmProvider Interface¶
Every LLM provider implements a single interface from halogen-core:
interface HalogenLlmProvider {
/**
* Generate a HalogenThemeSpec JSON string from a prompt.
*
* The engine builds the full prompt (system instructions, few-shot
* examples, extensions, user hint). The provider just sends it
* to the LLM and returns the raw JSON response.
*/
suspend fun generate(prompt: String): String
/**
* Check if this provider is currently available.
*/
suspend fun availability(): HalogenLlmAvailability
}
enum class HalogenLlmAvailability {
READY, // Can generate right now
INITIALIZING, // Downloading model, warming up
UNAVAILABLE // Not supported / no API key / no network
}
The provider is intentionally simple: it receives a fully-formed prompt and returns raw LLM output. The engine handles prompt construction, JSON parsing, validation, and caching.
Gemini Nano Setup (Android)¶
1. Add the provider dependency¶
// build.gradle.kts
dependencies {
implementation("me.mmckenna.halogen:halogen-provider-nano:0.1.0")
}
2. Initialize the provider¶
val nanoProvider = GeminiNanoProvider(
temperature = 0.2f, // Low temperature for consistent themes
topK = 10
)
3. Check availability¶
when (nanoProvider.availability()) {
HalogenLlmAvailability.READY -> { /* Good to go */ }
HalogenLlmAvailability.INITIALIZING -> {
// Model is downloading — collect the download flow
nanoProvider.downloadModel().collect { status ->
// Update UI with download progress
}
}
HalogenLlmAvailability.UNAVAILABLE -> {
// Device doesn't support Nano — consider using a cloud provider instead
}
}
Device requirements¶
- Pixel 9+, Samsung Galaxy S24+, or other devices with AICore (ML Kit)
- Locked bootloader required
- Foreground-only inference
- Per-app inference quota (mitigated by caching)
Cloud Provider Examples¶
OpenAI¶
class OpenAiProvider(
private val apiKey: String,
private val model: String = "gpt-4o-mini",
private val client: OkHttpClient = OkHttpClient(),
) : HalogenLlmProvider {
override suspend fun generate(prompt: String): String {
val body = """
{"model":"$model",
"messages":[{"role":"user","content":${prompt.toJsonString()}}],
"temperature":0.3,"max_tokens":256}
""".trimIndent()
val request = Request.Builder()
.url("https://api.openai.com/v1/chat/completions")
.addHeader("Authorization", "Bearer $apiKey")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
return withContext(Dispatchers.IO) {
val response = client.newCall(request).execute()
val json = JSONObject(response.body!!.string())
json.getJSONArray("choices")
.getJSONObject(0)
.getJSONObject("message")
.getString("content")
}
}
override suspend fun availability(): HalogenLlmAvailability {
return if (apiKey.isNotBlank()) HalogenLlmAvailability.READY
else HalogenLlmAvailability.UNAVAILABLE
}
}
Claude¶
class ClaudeProvider(
private val apiKey: String,
private val model: String = "claude-sonnet-4-20250514",
) : HalogenLlmProvider {
override suspend fun generate(prompt: String): String {
// POST to https://api.anthropic.com/v1/messages
// Return the content text from the response
}
override suspend fun availability(): HalogenLlmAvailability {
return if (apiKey.isNotBlank()) HalogenLlmAvailability.READY
else HalogenLlmAvailability.UNAVAILABLE
}
}
Ollama (Local)¶
class OllamaProvider(
private val baseUrl: String = "http://localhost:11434",
private val model: String = "llama3.2",
) : HalogenLlmProvider {
override suspend fun generate(prompt: String): String {
// POST to $baseUrl/api/generate
// Return the response text
}
override suspend fun availability(): HalogenLlmAvailability {
return try {
// GET $baseUrl/api/tags — if it responds, Ollama is running
HalogenLlmAvailability.READY
} catch (e: Exception) {
HalogenLlmAvailability.UNAVAILABLE
}
}
}
Single Provider Model¶
The engine uses a single HalogenLlmProvider. Pick the one that fits your use case:
// On-device with Gemini Nano (Android)
val halogen = Halogen.Builder()
.provider(GeminiNanoProvider())
.cache(HalogenCache.memory())
.build()
// Cloud provider (cross-platform)
val halogen = Halogen.Builder()
.provider(OpenAiProvider(apiKey = BuildConfig.OPENAI_KEY))
.cache(HalogenCache.memory())
.build()
What happens when the provider is unavailable?¶
The engine calls availability() before every generate() call. With a single provider, there's no fallback chain:
availability() returns |
Engine behavior |
|---|---|
READY |
Calls generate() normally |
INITIALIZING |
Waits up to 2 minutes, polling every 2 seconds. If it becomes READY, proceeds. If it times out, returns HalogenResult.Unavailable. |
UNAVAILABLE |
Returns HalogenResult.Unavailable immediately |
If generate() throws a HalogenLlmException, the engine returns HalogenResult.Unavailable. Your app should handle this gracefully, for example by showing the defaultTheme or a retry button.
Always set a default theme
Call .defaultTheme(HalogenDefaults.light()) on the Builder so your app has a usable theme even when the provider is unavailable.
Resolve chain¶
When you call resolve(key, hint), the engine tries each source in order:
1. Cache - instant, no network
2. Remote themes - pre-built themes from your backend (if configured)
3. LLM provider - generates a new theme from the hint
4. Default theme - static fallback (if configured)
Remote themes and the LLM provider serve different purposes. Remote themes fetch pre-built themes by key (no LLM involved). The LLM provider generates new themes from natural language. If you configure both, remote themes are checked first - this lets you curate themes for popular keys while the LLM handles everything else.
You can configure either or both:
// LLM only
Halogen.Builder()
.provider(GeminiNanoProvider())
.build()
// Remote themes only (no LLM, no generation)
Halogen.Builder()
.remoteThemes { key -> api.fetchTheme(key) }
.build()
// Both: remote themes for known keys, LLM for the rest
Halogen.Builder()
.provider(OpenAiProvider(apiKey))
.remoteThemes { key -> api.fetchTheme(key) }
.build()
Writing Your Own Provider¶
Implement HalogenLlmProvider and you're done. The contract is minimal:
generate(prompt)- Send the prompt string to your LLM, return the raw response. The engine handles JSON extraction and parsing.availability()- ReturnREADY,INITIALIZING, orUNAVAILABLE. The engine checks this before callinggenerate().
Keep providers simple
The provider should be a thin pipe to the LLM. Don't parse JSON, validate colors, or manage caching in the provider - the engine does all of that.
class MyCustomProvider : HalogenLlmProvider {
override suspend fun generate(prompt: String): String {
// 1. Send prompt to your LLM
// 2. Return the raw text response
// The engine will extract and parse the JSON
}
override suspend fun availability(): HalogenLlmAvailability {
// Return READY if the provider can generate right now
return HalogenLlmAvailability.READY
}
}
Error handling¶
Throw HalogenLlmException for failures: