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
74 changes: 73 additions & 1 deletion DISCOVERY.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,39 @@ POST /api/auth/2fa/verify-temp → { token, user }
### Configuration
```
GET /api/config → startup config (models, features, auth methods, interface config)
GET /api/endpoints → available AI providers and models
GET /api/endpoints → available AI providers and models (JWT required as of v0.8.5)
POST /api/user/settings/favorites → update user favorites (agent/model pins, v0.8.5+)
GET /api/user/settings/favorites → list user favorites (v0.8.5+)
POST /api/prompts/groups/:id/use → record prompt-group usage for analytics (v0.8.5+)
```

**v0.8.5 notes**
- `GET /api/config` response payload is now split into a pre-auth and post-auth variant.
Pre-auth fields (what `validateServerUrl` / `fetchStartupConfig` rely on) are unchanged
from v0.8.4; post-auth adds fields driven by the logged-in user (not consumed by mobile).
- `GET /api/config` removed `instanceProjectId`. Mobile previously used it as an OR fallback
in `ConfigRepositoryImpl.isValidLibreChatConfig`; cleanup landed in v0.8.5 sync.
- `GET /api/config` added `allowAccountDeletion: Boolean`. Mobile honors this and hides
the Delete Account button when `false`. Defaults to `true` for older servers that omit the field.
- `GET /api/endpoints` now requires JWT (was public in v0.8.4). Mobile already called it
post-auth, so this is non-breaking.
- Favorites schema: each entry has exactly one of `{ agentId }`, `{ model, endpoint }`,
or `{ spec }` — three mutually exclusive variants enforced by the server
(`FavoritesController.js`: "Each favorite must have either agentId, model+endpoint, or spec";
`model` and `endpoint` must be supplied together; combining `spec` with any of
`agentId`/`model`/`endpoint` is rejected). Server also enforces 50 entries max /
256-character max per string; mobile short-circuits oversize writes. The `spec`
variant is round-tripped unchanged (mobile does not yet render a spec picker).
`POST` replaces the entire list (upsert-by-overwrite), and the response echoes the stored list.

### Out of scope (admin panel)
```
/api/admin/auth/** → admin-only SAML + Social OAuth callbacks (v0.8.5, web-only)
/api/admin/config/** → admin YAML config endpoints
/api/admin/grants/**, /api/admin/groups/**, /api/admin/roles/**, /api/admin/users/**
```
Admin panel is a web-only surface in upstream; mobile intentionally does not implement it.

### Conversations
```
GET /api/convos → { conversations, nextCursor }
Expand Down Expand Up @@ -212,6 +242,34 @@ GET /api/banner
- files, attachments, feedback
- error, unfinished, finish_reason, tokenCount

### MessageContentPart
Discriminated by `type`: `text`, `think`, `text_delta`, `tool_call`, `image_file`,
`image_url`, `video_url`, `input_audio`, `agent_update`, `summary`, `error`.

**SUMMARY part wire shape (v0.8.5+)** — context-compaction emits a content part with
fields at the top level (not nested under a `summary` key):
```
{
"type": "summary",
"content": [{"type":"text","text":"..."}], // array OR string (two variants)
"tokenCount": 42,
"summarizing": false,
"summaryVersion": 1,
"model": "gpt-4o",
"provider": "openai",
"createdAt": "2026-04-22T...",
"boundary": {"messageId": "...", "contentIndex": 0}
}
```
Variants for the body text (mirrors upstream `BaseClient.getSummaryText`, last-wins):
1. `content: Array<{type:"text", text}>` — new default since v0.8.5.
2. `content: string` — intermediate variant; rare but emitted by some code paths.
3. No `content`; `text: "..."` at the top level — legacy fallback from pre-v0.8.5
summarization or test fixtures.

Mobile's `MessageContentPart.content` is typed `JsonElement?` to absorb variants 1/2,
and falls back to the existing `text: String?` field for variant 3.

### File
- file_id, filename, filepath, type, bytes, source
- user, conversationId, messageId
Expand All @@ -226,6 +284,20 @@ GET /api/banner
3. Receive events: message, step, created, attachment, final, sync, error
4. On disconnect: reconnect with `?resume=true` for sync event

### Agent-library event names (v0.8.5)
`on_message_delta`, `on_reasoning_delta`, `on_run_step`, `on_run_step_delta`,
`on_run_step_completed`, `on_chat_model_end`, `on_agent_update`, `attachment`, and —
added in v0.8.5 — `on_summarize_start`, `on_summarize_delta`, `on_summarize_complete`.

`on_summarize_complete` payload nests the finished summary block under a `summary` key
(distinct from the message-persistence SUMMARY content part described in
`MessageContentPart`):
```
{"id":"...","agentId":"...","summary":{"type":"summary","content":[{"type":"text","text":"..."}],...}}
```
Mobile only renders the compacted summary once it is persisted to the final message as
a SUMMARY content part; the delta/lifecycle events are surfaced as status only.

### SSE Event Format
```
event: message
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# LibreChat Mobile

[![LibreChat](https://img.shields.io/badge/LibreChat-v0.8.4-blue)](https://github.com/danny-avila/LibreChat/releases/tag/v0.8.4)
[![LibreChat](https://img.shields.io/badge/LibreChat-v0.8.4_–_v0.8.5-blue)](https://github.com/danny-avila/LibreChat/releases/tag/v0.8.5)

A third-party native mobile client for [LibreChat](https://www.librechat.ai/) (Android & iOS). Not affiliated with the official LibreChat project — this is an independent app that connects to any self-hosted LibreChat server, no backend modifications required.

> **Backend compatibility:** Tested against LibreChat **v0.8.4 – v0.8.5**. Older releases may work but are not guaranteed; newer releases are supported on a best-effort basis until the next sync.

## Features

- **Chat** — Real-time streaming (SSE), message branching & sibling navigation, stop/regenerate/continue, markdown with syntax highlighting, LaTeX math rendering, code blocks with copy, image display, file attachments, tool call progress cards
Expand Down
6 changes: 3 additions & 3 deletions UPSTREAM_VERSION
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Upstream LibreChat version this mobile build tracks.
# Updated by the sync-upstream skill. Do not edit manually.
tag=v0.8.4
commit=0736ff26686e911c9785a237c63a799db1813f0b
date=2026-03-26
tag=v0.8.5
commit=9ccc8d9bef407f9a769f07a3756ec4b95ac13f80
date=2026-04-23
32 changes: 32 additions & 0 deletions VERSION_GATES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Version Gates

LibreChat Mobile supports a range of backend server versions. This file catalogs every
place in the codebase where behavior branches based on the detected server version so
the compatibility surface is auditable. When the minimum supported server version is
raised, entries here can be simplified or removed.

The canonical API for version comparisons lives in
`core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/BackendVersion.kt`:

- `BackendVersion.parse(version)` — parse a loose semver string (`"v0.8.5"`, `"0.8"`, …).
- `BackendVersion.isCompatible(supported, actual)` — same-`major.minor` check (patch ignored).
- `BackendVersion.isCompatibleOrNewer(actual, minimum)` — `actual ≥ minimum` by `(major, minor)`.
- `BackendVersion.extractVersionFromFooter(footer)` — fallback parse from `customFooter`.

The detected server version is exposed via `ConfigRepository.detectedBackendVersion`
(populated once `checkBackendVersion()` runs on app startup / server-switch).

## Catalog

| Feature | Gated since | Behavior on older | Behavior on newer | File:line | Safe to remove when min supported server ≥ |
|---|---|---|---|---|---|
| `isCollaborative` agent toggle | v0.8.5 (2026-04-23) | Toggle visible; mobile sends `isCollaborative` + `projectIds` to server | Toggle hidden; inline hint "Access permissions are managed server-side in this version" rendered instead; fields not sent | `feature/agents/.../components/AgentSharingSection.kt` + `feature/agents/.../viewmodel/AgentEditorViewModel.kt` (`observeServerVersion`, `save`) | v0.8.5 |
| `xhigh` reasoning-effort dropdown value | v0.8.5 (2026-04-25) | `xhigh` filtered out of `reasoning_effort` and `effort` dropdowns (older Anthropic/Bedrock/OpenAI schemas reject the unknown enum) | `xhigh` shown alongside `low/medium/high/max` | `core/ui/.../components/EndpointParameterRegistry.kt` (`getDefinitions(xhighEffortSupported)`) + `feature/chat/.../viewmodel/ChatViewModel.kt` (xhigh observer in `init`) | v0.8.5 |

## Guidelines for adding a new gate

1. Call `BackendVersion.isCompatible(...)` or `BackendVersion.isCompatibleOrNewer(...)` — never parse versions ad hoc.
2. Default to **older-server behavior** when the version is unknown (`detectedBackendVersion == null`). The server may not advertise its version; failing open avoids hiding features from self-hosted installs with stripped customFooters.
3. Add a row to the table above. Include file + line anchors and the concrete minimum version at which the gate becomes dead code.
4. If the gated field is a request DTO field, omit it (send `null`) rather than sending a value the server will silently drop — unless you can verify round-trip parity. Silent drops lead to UI state that disagrees with server state.
5. Never gate on patch versions; the version detection is best-effort and the compat check is minor-version-only.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.garfiec.librechat.core.data.repository.ChatRepository
import com.garfiec.librechat.core.data.repository.ConfigRepository
import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.DraftRepository
import com.garfiec.librechat.core.data.repository.FavoritesRepository
import com.garfiec.librechat.core.data.repository.FileRepository
import com.garfiec.librechat.core.data.repository.KeyRepository
import com.garfiec.librechat.core.data.repository.McpRepository
Expand All @@ -42,6 +43,7 @@ import com.garfiec.librechat.core.network.api.BannerApi
import com.garfiec.librechat.core.network.api.ChatApi
import com.garfiec.librechat.core.network.api.ConfigApi
import com.garfiec.librechat.core.network.api.ConversationsApi
import com.garfiec.librechat.core.network.api.FavoritesApi
import com.garfiec.librechat.core.network.api.FilesApi
import com.garfiec.librechat.core.network.api.FilesExtApi
import com.garfiec.librechat.core.network.api.KeysApi
Expand Down Expand Up @@ -125,6 +127,7 @@ class KoinGraphVerificationTest {
ChatApi::class,
ConfigApi::class,
ConversationsApi::class,
FavoritesApi::class,
FilesApi::class,
FilesExtApi::class,
KeysApi::class,
Expand Down Expand Up @@ -152,6 +155,7 @@ class KoinGraphVerificationTest {
ConfigRepository::class,
ConversationRepository::class,
DraftRepository::class,
FavoritesRepository::class,
FileRepository::class,
KeyRepository::class,
McpRepository::class,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ object BackendVersion {
* Matches the VERSION constant from the official LibreChat repo's
* `packages/data-provider/src/config.ts` and `package.json`.
*/
const val SUPPORTED_BACKEND_VERSION = "0.8.4"
const val SUPPORTED_BACKEND_VERSION = "0.8.5"

/**
* Represents a parsed semantic version (major.minor.patch).
Expand Down Expand Up @@ -60,6 +60,37 @@ object BackendVersion {
supportedVersion.minor == actualVersion.minor
}

/**
* Checks whether [actual] is greater than or equal to [minimum] (feature-gate check).
*
* Use this when branching on whether a backend feature was introduced in a
* specific version. Patch differences are ignored. Returns true when [actual]
* cannot be parsed (fail-open: assume feature is present). Returns false when
* [minimum] cannot be parsed (degenerate threshold).
*
* **Contract for callers:** null-check or explicitly handle the unknown-version
* case before invoking. The fail-open default here is intentional because in
* practice this helper is called only after `ConfigRepositoryImpl.checkBackendVersion()`
* has persisted either a parsed-valid version string or an explicit `null` to
* `ConfigRepository.detectedBackendVersion` — garbage never reaches this helper.
* This is a deliberate divergence from the "default to older-server behavior on
* unknown version" guideline in `VERSION_GATES.md` §Guidelines #2: that guideline
* is the callsite rule, and this helper only runs once the callsite has resolved
* the unknown-version case upstream.
*
* @param actual The version detected from the server (e.g., "0.8.5").
* @param minimum The minimum version at which the gated feature appears (e.g., "0.8.5").
* @return true if [actual] ≥ [minimum] by (major, minor), false otherwise.
*/
fun isCompatibleOrNewer(actual: String, minimum: String): Boolean {
val actualVersion = parse(actual) ?: return true
val minimumVersion = parse(minimum) ?: return false
if (actualVersion.major != minimumVersion.major) {
return actualVersion.major > minimumVersion.major
}
return actualVersion.minor >= minimumVersion.minor
}

/**
* Extracts a version string from a LibreChat customFooter value.
* The default footer format is: `[LibreChat vX.Y.Z](https://librechat.ai) - ...`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ class ApiException(
val statusCode: Int,
override val message: String,
val isBanned: Boolean = false,
) : Exception(message)
cause: Throwable? = null,
) : Exception(message, cause)
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.garfiec.librechat.core.network.api.BannerApi
import com.garfiec.librechat.core.network.api.ChatApi
import com.garfiec.librechat.core.network.api.ConfigApi
import com.garfiec.librechat.core.network.api.ConversationsApi
import com.garfiec.librechat.core.network.api.FavoritesApi
import com.garfiec.librechat.core.network.api.FilesApi
import com.garfiec.librechat.core.network.api.FilesExtApi
import com.garfiec.librechat.core.network.api.KeysApi
Expand Down Expand Up @@ -53,6 +54,7 @@ class DataModuleVerificationTest {
ChatApi::class,
ConversationsApi::class,
MessagesApi::class,
FavoritesApi::class,
FilesApi::class,
FilesExtApi::class,
AgentsApi::class,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.ConversationRepositoryImpl
import com.garfiec.librechat.core.data.repository.DraftRepository
import com.garfiec.librechat.core.data.repository.DraftRepositoryImpl
import com.garfiec.librechat.core.data.repository.FavoritesRepository
import com.garfiec.librechat.core.data.repository.FavoritesRepositoryImpl
import com.garfiec.librechat.core.data.repository.FileRepository
import com.garfiec.librechat.core.data.repository.FileRepositoryImpl
import com.garfiec.librechat.core.data.repository.KeyRepository
Expand Down Expand Up @@ -167,4 +169,5 @@ val dataModule = module {
singleOf(::SpeechRepositoryImpl) bind SpeechRepository::class
singleOf(::UserRepositoryImpl) bind UserRepository::class
singleOf(::BannerRepositoryImpl) bind BannerRepository::class
singleOf(::FavoritesRepositoryImpl) bind FavoritesRepository::class
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ interface ConfigRepository {
val startupConfig: StateFlow<StartupConfig?>
val endpointConfigs: StateFlow<Map<String, EndpointConfig>>
val availableModels: StateFlow<Map<String, List<String>>>

/**
* The backend version detected from `/api/config` (via the `version` field
* or the `customFooter` pattern). `null` until [checkBackendVersion] runs
* or if the backend does not expose its version.
*
* UI code should consult [com.garfiec.librechat.core.common.BackendVersion.isCompatible]
* when branching on this value and consult `VERSION_GATES.md` at the repo root
* when adding new gates.
*/
val detectedBackendVersion: StateFlow<String?>

suspend fun validateServerUrl(url: String): Result<StartupConfig>
suspend fun fetchStartupConfig(): Result<StartupConfig>
suspend fun fetchEndpoints(): Result<Map<String, EndpointConfig>>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ class ConfigRepositoryImpl(
private val _availableModels = MutableStateFlow<Map<String, List<String>>>(emptyMap())
override val availableModels: StateFlow<Map<String, List<String>>> = _availableModels.asStateFlow()

private val _detectedBackendVersion = MutableStateFlow<String?>(null)
override val detectedBackendVersion: StateFlow<String?> = _detectedBackendVersion.asStateFlow()

override suspend fun validateServerUrl(url: String): Result<StartupConfig> {
return try {
val config = configApi.getStartupConfig()
Expand Down Expand Up @@ -57,11 +60,12 @@ class ConfigRepositoryImpl(

/**
* Validates that the config response contains fields specific to LibreChat.
* The `serverDomain` and `instanceProjectId` fields are distinctive to LibreChat's
* /api/config endpoint and unlikely to appear in arbitrary JSON APIs.
* `serverDomain` is a required field on LibreChat's /api/config and has
* defaulted to a non-blank value since v0.7; arbitrary JSON APIs will not
* populate it.
*/
private fun isValidLibreChatConfig(config: StartupConfig): Boolean {
return config.serverDomain.isNotBlank() || config.instanceProjectId != null
return config.serverDomain.isNotBlank()
}

override suspend fun fetchStartupConfig(): Result<StartupConfig> {
Expand Down Expand Up @@ -163,6 +167,8 @@ class ConfigRepositoryImpl(
// Strategy 2: Parse customFooter for version pattern
?: BackendVersion.extractVersionFromFooter(config?.customFooter)

_detectedBackendVersion.value = detectedVersion

if (detectedVersion != null) {
Logger.d { "Backend version detected: $detectedVersion (supported: $supported)" }
VersionCheckResult(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.garfiec.librechat.core.data.repository

import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.model.UserFavorite
import kotlinx.coroutines.flow.StateFlow

/**
* Single source of truth for the user's pinned favorites list.
*
* Both the chat-side pin toggles and the Settings → Favorites screen
* observe [favorites] and route writes through [setFavorites], so the
* two surfaces never drift apart and concurrent writes can't lose each
* other (the impl serializes them with a [kotlinx.coroutines.sync.Mutex]).
*/
interface FavoritesRepository {
/**
* Latest known favorites list. Empty until [refresh] or [setFavorites]
* has populated it. UI should observe this rather than caching its own copy.
*/
val favorites: StateFlow<List<UserFavorite>>

/** Fetches the canonical list from the server and publishes it to [favorites]. */
suspend fun refresh(): Result<List<UserFavorite>>

/**
* Replaces the server-side favorites list with [list]. The new list is
* published to [favorites] optimistically before the network call returns;
* on error the cache is rolled back from the server. Concurrent calls are
* serialized so no write is silently lost.
*/
suspend fun setFavorites(list: List<UserFavorite>): Result<List<UserFavorite>>
}
Loading
Loading