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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ android {
// versionCode/versionName are overridable from Gradle properties so the release CI
// can drive them straight from the git tag (e.g. -PappVersionCode=5 -PappVersionName=1.0.0).
// Local builds fall back to the literals below.
versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 37
versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 38
versionName = (project.findProperty("appVersionName") as String?) ?: "2.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.pulseloop.coach.config

import com.pulseloop.coach.gemini.GeminiClient
import com.pulseloop.coach.local.LocalEndpoint
import com.pulseloop.coach.local.LocalOpenAICompatClient
import com.pulseloop.coach.minimax.MiniMaxClient
import com.pulseloop.coach.openai.OpenAIResponsesClient
import com.pulseloop.coach.openai.ResponsesClient
Expand All @@ -18,6 +20,11 @@ import com.pulseloop.settings.ApiKeyStore
* run (used to gate `CoachFeatureFlags.coachEnabled`). A client is returned
* even when the key is absent (`key == null`); the feature-flags gate prevents
* an empty-key call.
*
* For [CoachProviderMode.LOCAL_OPENAI_COMPAT] the sentinel is the **base URL**, not a key: a
* self-hosted Ollama/llama.cpp/vLLM/SGLang server is unauthenticated by default, so requiring a
* key there would leave the coach permanently disabled for the normal setup. See
* `docs/local-llm-coach.md`.
*/
object CoachClientResolver {

Expand All @@ -30,6 +37,7 @@ object CoachClientResolver {
geminiKey: String?,
openRouterKey: String?,
minimaxKey: String? = null,
localKey: String? = null,
openAIClientFactory: (String) -> ResponsesClient = { OpenAIResponsesClient(it) },
): Resolution = when (settings.providerMode) {
CoachProviderMode.USER_GEMINI_KEY -> {
Expand All @@ -48,6 +56,24 @@ object CoachClientResolver {
val key = minimaxKey?.takeIf { it.isNotBlank() }
Resolution(key, MiniMaxClient(apiKey = key ?: "", model = settings.resolvedMinimaxModel))
}
CoachProviderMode.LOCAL_OPENAI_COMPAT -> {
// Readiness is a base URL that would actually work — `validate`, not `isNotBlank`.
// Settings persists the field on every keystroke, so a blank-check flips the coach to
// "Active" on the first character typed, and every turn then fails inside `send()`
// with the same URL-validation text the Settings field is already showing inline.
// `localKey` may legitimately be blank and is passed through as null so the client
// omits the Authorization header entirely.
val baseUrl = settings.resolvedLocalBaseUrl
Resolution(baseUrl.takeIf { LocalEndpoint.validate(it) == null }, LocalOpenAICompatClient(
baseUrl = baseUrl,
model = settings.resolvedLocalModel,
apiKey = localKey?.takeIf { it.isNotBlank() },
toolCallingEnabled = settings.localToolCalling,
structuredOutput = settings.localStructuredOutput,
maxOutputTokens = settings.localMaxTokens.takeIf { it > 0 },
readTimeoutSeconds = settings.localTimeoutSeconds,
))
}
else -> {
// USER_OPENAI_KEY / OFFLINE_STUB / BACKEND_PROXY all use the OpenAI
// key + factory, mirroring the iOS resolver.
Expand All @@ -67,6 +93,7 @@ object CoachClientResolver {
geminiKey = store.geminiApiKey,
openRouterKey = store.openRouterApiKey,
minimaxKey = store.minimaxApiKey,
localKey = store.localApiKey,
openAIClientFactory = openAIClientFactory,
)

Expand Down Expand Up @@ -101,6 +128,9 @@ object CoachClientResolver {
CoachProviderMode.USER_GEMINI_KEY -> settings.geminiModel
CoachProviderMode.USER_OPENROUTER_KEY -> settings.resolvedOpenRouterModel
CoachProviderMode.USER_MINIMAX_KEY -> settings.resolvedMinimaxModel
// Reported verbatim for attribution — including blank, which is a valid llama.cpp
// setup. `CoachPricingCatalog` prices this provider at $0 regardless of the string.
CoachProviderMode.LOCAL_OPENAI_COMPAT -> settings.resolvedLocalModel
else -> openAIModel.ifEmpty { OpenAIModel.DEFAULT.slug }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ enum class CoachProviderMode(val rawValue: String, val label: String) {
USER_GEMINI_KEY("userGeminiKey", "Gemini (your key)"),
USER_OPENROUTER_KEY("userOpenRouterKey", "OpenRouter (your key)"),
USER_MINIMAX_KEY("userMiniMaxKey", "MiniMax (your key)"),
/** Any OpenAI-Chat-Completions-compatible server the user runs themselves — Ollama,
* llama.cpp, vLLM, SGLang, LM Studio. The API key is optional here; the readiness sentinel
* is the base URL. See `docs/local-llm-coach.md`. */
LOCAL_OPENAI_COMPAT("localOpenAICompat", "Local / self-hosted"),
BACKEND_PROXY("backendProxy", "Backend proxy");

companion object {
Expand Down Expand Up @@ -48,6 +52,21 @@ data class CoachProviderSettings(
val reasoningEffort: String? = null,
/** When true, the coach composer shows an attach-image button. */
val imageInputEnabled: Boolean = false,
/** Local-only: base URL of the self-hosted server, as typed (`http://192.168.1.50:11434`).
* Blank means the provider isn't configured — this, not the key, gates readiness. */
val localBaseUrl: String = "",
/** Local-only: model name the server expects. Free-form; `/v1/models` populates the picker. */
val localModel: String = "",
/** Local-only: send `tools`. Off for a server started without tool-call support (vLLM without
* `--enable-auto-tool-choice` returns HTTP 400) or a model that can't call them. */
val localToolCalling: Boolean = true,
/** Local-only: how hard to constrain the output shape. Default OFF — the only mode every
* backend supports. */
val localStructuredOutput: LocalStructuredOutput = LocalStructuredOutput.OFF,
/** Local-only: `max_tokens`; 0 = omit and let the server decide. */
val localMaxTokens: Int = 0,
/** Local-only: read timeout in seconds. Long, because CPU inference is slow. */
val localTimeoutSeconds: Int = com.pulseloop.coach.local.LocalOpenAICompatClient.DEFAULT_READ_TIMEOUT_SECONDS,
) {
/** The OpenRouter model slug to use; falls back to the default preset only
* when the stored slug is blank. */
Expand All @@ -58,4 +77,33 @@ data class CoachProviderSettings(
* the stored slug is blank. */
val resolvedMinimaxModel: String
get() = minimaxModel.trim().ifEmpty { MiniMaxModel.DEFAULT.slug }

/** The local base URL with surrounding whitespace gone; blank when unconfigured. There's no
* default to fall back to — every engine listens on a different port. */
val resolvedLocalBaseUrl: String get() = localBaseUrl.trim()

/** The local model name. Blank is sent as-is rather than substituted: llama.cpp ignores the
* field entirely, so an empty value is legitimate there, and inventing a slug would turn a
* working setup into a 404 on the servers that do read it. */
val resolvedLocalModel: String get() = localModel.trim()
}

/**
* How hard to constrain a local model's output shape — see `docs/local-llm-coach.md` §5.
*
* [OFF] is the default because it's the only mode implemented by every backend: the coach's shape
* is carried by `CoachResponseSchema.promptInstruction` in the system message, with the
* orchestrator's JSON-repair loop as the backstop. The other two are opt-in because the support
* matrix is genuinely uneven — LM Studio implements `json_schema` but not `json_object`, and some
* llama.cpp builds error when `json_schema` collides with a server-side `grammar`.
*/
enum class LocalStructuredOutput(val rawValue: String, val label: String, val blurb: String) {
OFF("off", "Prompt only", "Works everywhere (default)"),
JSON_OBJECT("jsonObject", "JSON mode", "response_format: json_object — not on LM Studio"),
JSON_SCHEMA("jsonSchema", "Strict schema", "response_format: json_schema — best when supported");

companion object {
fun fromRaw(raw: String?): LocalStructuredOutput =
entries.firstOrNull { it.rawValue == raw } ?: OFF
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,52 @@ class CoachProviderSettingsStore(context: Context) {
else prefs.edit().putString(KEY_REASONING_EFFORT, value).apply()
}

// ── Local / self-hosted provider (docs/local-llm-coach.md) ──────────
// The base URL lives in the same encrypted file as the keys: it's a private network address,
// and it's what gates readiness in place of a key.

/** Base URL of the self-hosted OpenAI-compatible server, as the user typed it. */
var localBaseUrl: String
get() = prefs.getString(KEY_LOCAL_BASE_URL, "") ?: ""
set(value) { prefs.edit().putString(KEY_LOCAL_BASE_URL, value).apply() }

val hasLocalBaseUrl: Boolean get() = localBaseUrl.isNotBlank()

/** OPTIONAL bearer token. Blank is the normal case — llama.cpp/vLLM/SGLang only require one
* when started with `--api-key`, and Ollama ignores the header entirely. */
var localApiKey: String
get() = prefs.getString(KEY_LOCAL_API_KEY, "") ?: ""
set(value) { prefs.edit().putString(KEY_LOCAL_API_KEY, value).apply() }

val hasLocalKey: Boolean get() = localApiKey.isNotBlank()

/** Model name the server expects; free-form (the `/v1/models` picker just fills it in). */
var localModel: String
get() = prefs.getString(KEY_LOCAL_MODEL, "") ?: ""
set(value) { prefs.edit().putString(KEY_LOCAL_MODEL, value).apply() }

/** Send `tools` to the local server. Default on. */
var localToolCalling: Boolean
get() = prefs.getBoolean(KEY_LOCAL_TOOL_CALLING, true)
set(value) { prefs.edit().putBoolean(KEY_LOCAL_TOOL_CALLING, value).apply() }

/** How hard to constrain the output shape; default [LocalStructuredOutput.OFF]. */
var localStructuredOutput: LocalStructuredOutput
get() = LocalStructuredOutput.fromRaw(prefs.getString(KEY_LOCAL_STRUCTURED_OUTPUT, null))
set(value) { prefs.edit().putString(KEY_LOCAL_STRUCTURED_OUTPUT, value.rawValue).apply() }

/** `max_tokens` for local requests; 0 = omit the field. */
var localMaxTokens: Int
get() = prefs.getInt(KEY_LOCAL_MAX_TOKENS, 0)
set(value) { prefs.edit().putInt(KEY_LOCAL_MAX_TOKENS, maxOf(0, value)).apply() }

/** Read timeout for local requests, in seconds. Clamped to a sane floor so a stray `1` can't
* make every turn time out with no way back except reinstalling. */
var localTimeoutSeconds: Int
get() = prefs.getInt(KEY_LOCAL_TIMEOUT_SECONDS,
com.pulseloop.coach.local.LocalOpenAICompatClient.DEFAULT_READ_TIMEOUT_SECONDS)
set(value) { prefs.edit().putInt(KEY_LOCAL_TIMEOUT_SECONDS, value.coerceIn(10, 1800)).apply() }

/** Master toggle for the coach composer's attach-image button. */
var imageInputEnabled: Boolean
get() = prefs.getBoolean(KEY_IMAGE_INPUT_ENABLED, false)
Expand All @@ -98,6 +144,12 @@ class CoachProviderSettingsStore(context: Context) {
orProviderSort = orProviderSort,
reasoningEffort = reasoningEffort,
imageInputEnabled = imageInputEnabled,
localBaseUrl = localBaseUrl,
localModel = localModel,
localToolCalling = localToolCalling,
localStructuredOutput = localStructuredOutput,
localMaxTokens = localMaxTokens,
localTimeoutSeconds = localTimeoutSeconds,
)

companion object {
Expand All @@ -112,5 +164,12 @@ class CoachProviderSettingsStore(context: Context) {
private const val KEY_OR_PROVIDER_SORT = "openrouter_provider_sort"
private const val KEY_REASONING_EFFORT = "coach_reasoning_effort"
private const val KEY_IMAGE_INPUT_ENABLED = "coach_image_input_enabled"
private const val KEY_LOCAL_BASE_URL = "local_llm_base_url"
private const val KEY_LOCAL_API_KEY = "local_llm_api_key"
private const val KEY_LOCAL_MODEL = "local_llm_model"
private const val KEY_LOCAL_TOOL_CALLING = "local_llm_tool_calling"
private const val KEY_LOCAL_STRUCTURED_OUTPUT = "local_llm_structured_output"
private const val KEY_LOCAL_MAX_TOKENS = "local_llm_max_tokens"
private const val KEY_LOCAL_TIMEOUT_SECONDS = "local_llm_timeout_seconds"
}
}
Loading