feat(common): include patch in backend version compatibility checks

Upstream LibreChat ships breaking API and SSE-shape changes inside patch
releases (e.g. 0.8.4 → 0.8.5 changed the SSE payload contract), so the
mobile compat surface must compare patch as well as major.minor.

- isCompatible: exact (major, minor, patch) equality
- isCompatibleOrNewer: lex compare over (major, minor, patch); fixes a
  silent bug where 0.8.5-only feature gates returned true on 0.8.4
- VERSION_GATES.md updated to reflect patch-aware semantics
- New BackendVersionTest covers parse, isCompatible, isCompatibleOrNewer,
  and extractVersionFromFooter
This commit is contained in:
Garfie 2026-04-27 00:02:56 -06:00
parent c7b5b7a365
commit 89eaccb428
3 changed files with 172 additions and 13 deletions

View file

@ -9,8 +9,8 @@ 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.isCompatible(supported, actual)`exact `major.minor.patch` match.
- `BackendVersion.isCompatibleOrNewer(actual, minimum)``actual ≥ minimum` by `(major, minor, patch)`.
- `BackendVersion.extractVersionFromFooter(footer)` — fallback parse from `customFooter`.
The detected server version is exposed via `ConfigRepository.detectedBackendVersion`
@ -29,4 +29,4 @@ The detected server version is exposed via `ConfigRepository.detectedBackendVers
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.
5. Patch-version gates are supported. Upstream LibreChat regularly ships breaking API and SSE-shape changes inside a patch bump (the same-minor assumption broke us moving 0.8.4 → 0.8.5), so use the exact patch the feature shipped in.

View file

@ -47,26 +47,31 @@ object BackendVersion {
/**
* Checks whether two versions are compatible.
* A mismatch in major or minor version is considered incompatible.
* Patch differences are ignored (considered compatible).
* Any mismatch in major, minor, or patch is considered incompatible.
*
* @return true if the versions are compatible (same major and minor), false otherwise.
* Patch is included because upstream LibreChat has shipped breaking API
* changes within the same minor version (e.g. SSE payload shape changes
* between 0.8.4 and 0.8.5). Treating patch differences as compatible would
* silently mask those.
*
* @return true if the versions match exactly (major, minor, patch), false otherwise.
* Returns true if either version cannot be parsed (fail-open).
*/
fun isCompatible(supported: String, actual: String): Boolean {
val supportedVersion = parse(supported) ?: return true
val actualVersion = parse(actual) ?: return true
return supportedVersion.major == actualVersion.major &&
supportedVersion.minor == actualVersion.minor
return supportedVersion == actualVersion
}
/**
* 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).
* specific version. Comparison is lexicographic over (major, minor, patch)
* patch is included because upstream sometimes ships features and breaking
* changes within a patch release. 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
@ -80,7 +85,7 @@ object BackendVersion {
*
* @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.
* @return true if [actual] [minimum] by (major, minor, patch), false otherwise.
*/
fun isCompatibleOrNewer(actual: String, minimum: String): Boolean {
val actualVersion = parse(actual) ?: return true
@ -88,7 +93,10 @@ object BackendVersion {
if (actualVersion.major != minimumVersion.major) {
return actualVersion.major > minimumVersion.major
}
return actualVersion.minor >= minimumVersion.minor
if (actualVersion.minor != minimumVersion.minor) {
return actualVersion.minor > minimumVersion.minor
}
return actualVersion.patch >= minimumVersion.patch
}
/**

View file

@ -0,0 +1,151 @@
package com.garfiec.librechat.core.common
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class BackendVersionTest {
// region parse
@Test
fun parseAcceptsFullSemver() {
val v = BackendVersion.parse("0.8.5")
assertEquals(BackendVersion.SemanticVersion(0, 8, 5), v)
}
@Test
fun parseAcceptsLeadingV() {
assertEquals(BackendVersion.SemanticVersion(1, 2, 3), BackendVersion.parse("v1.2.3"))
assertEquals(BackendVersion.SemanticVersion(1, 2, 3), BackendVersion.parse("V1.2.3"))
}
@Test
fun parseDefaultsPatchToZero() {
assertEquals(BackendVersion.SemanticVersion(0, 8, 0), BackendVersion.parse("0.8"))
}
@Test
fun parseRejectsGarbage() {
assertNull(BackendVersion.parse("garbage"))
assertNull(BackendVersion.parse("1"))
assertNull(BackendVersion.parse("1.x.0"))
assertNull(BackendVersion.parse(""))
}
// endregion
// region isCompatible
@Test
fun isCompatibleExactMatch() {
assertTrue(BackendVersion.isCompatible("0.8.5", "0.8.5"))
}
@Test
fun isCompatibleRejectsPatchMismatch() {
// The reason this function exists in its tightened form: upstream ships
// breaking changes inside the same minor (0.8.4 → 0.8.5 changed SSE shape).
assertFalse(BackendVersion.isCompatible("0.8.5", "0.8.4"))
assertFalse(BackendVersion.isCompatible("0.8.5", "0.8.6"))
}
@Test
fun isCompatibleRejectsMinorMismatch() {
assertFalse(BackendVersion.isCompatible("0.8.5", "0.9.0"))
assertFalse(BackendVersion.isCompatible("0.8.5", "0.7.0"))
}
@Test
fun isCompatibleRejectsMajorMismatch() {
assertFalse(BackendVersion.isCompatible("0.8.5", "1.0.0"))
}
@Test
fun isCompatibleFailsOpenOnUnparseableInput() {
assertTrue(BackendVersion.isCompatible("garbage", "0.8.5"))
assertTrue(BackendVersion.isCompatible("0.8.5", "garbage"))
}
// endregion
// region isCompatibleOrNewer
@Test
fun isCompatibleOrNewerExactMatchPasses() {
assertTrue(BackendVersion.isCompatibleOrNewer("0.8.5", "0.8.5"))
}
@Test
fun isCompatibleOrNewerRejectsLowerPatch() {
// Pre-fix bug: this returned true because patch was ignored, silently
// activating 0.8.5-only features on a 0.8.4 server.
assertFalse(BackendVersion.isCompatibleOrNewer("0.8.4", "0.8.5"))
}
@Test
fun isCompatibleOrNewerAcceptsHigherPatch() {
assertTrue(BackendVersion.isCompatibleOrNewer("0.8.6", "0.8.5"))
}
@Test
fun isCompatibleOrNewerAcceptsHigherMinor() {
assertTrue(BackendVersion.isCompatibleOrNewer("0.9.0", "0.8.5"))
}
@Test
fun isCompatibleOrNewerRejectsLowerMinor() {
assertFalse(BackendVersion.isCompatibleOrNewer("0.7.99", "0.8.5"))
}
@Test
fun isCompatibleOrNewerAcceptsHigherMajor() {
assertTrue(BackendVersion.isCompatibleOrNewer("1.0.0", "0.8.5"))
}
@Test
fun isCompatibleOrNewerRejectsLowerMajor() {
assertFalse(BackendVersion.isCompatibleOrNewer("0.8.5", "1.0.0"))
}
@Test
fun isCompatibleOrNewerFailsOpenOnUnparseableActual() {
assertTrue(BackendVersion.isCompatibleOrNewer("garbage", "0.8.5"))
}
@Test
fun isCompatibleOrNewerReturnsFalseOnUnparseableMinimum() {
assertFalse(BackendVersion.isCompatibleOrNewer("0.8.5", "garbage"))
}
// endregion
// region extractVersionFromFooter
@Test
fun extractVersionFromFooterParsesDefaultMarkdown() {
val footer = "[LibreChat v0.8.5](https://librechat.ai) - Free, Open Source"
assertEquals("0.8.5", BackendVersion.extractVersionFromFooter(footer))
}
@Test
fun extractVersionFromFooterParsesPlainText() {
assertEquals("0.8.5", BackendVersion.extractVersionFromFooter("LibreChat v0.8.5"))
}
@Test
fun extractVersionFromFooterAcceptsTwoDigitVersions() {
assertEquals("0.8", BackendVersion.extractVersionFromFooter("LibreChat v0.8"))
}
@Test
fun extractVersionFromFooterReturnsNullOnNoMatch() {
assertNull(BackendVersion.extractVersionFromFooter(null))
assertNull(BackendVersion.extractVersionFromFooter(""))
assertNull(BackendVersion.extractVersionFromFooter("custom footer text"))
}
// endregion
}