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:
parent
c7b5b7a365
commit
89eaccb428
3 changed files with 172 additions and 13 deletions
|
|
@ -9,8 +9,8 @@ The canonical API for version comparisons lives in
|
||||||
`core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/BackendVersion.kt`:
|
`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.parse(version)` — parse a loose semver string (`"v0.8.5"`, `"0.8"`, …).
|
||||||
- `BackendVersion.isCompatible(supported, actual)` — same-`major.minor` check (patch ignored).
|
- `BackendVersion.isCompatible(supported, actual)` — exact `major.minor.patch` match.
|
||||||
- `BackendVersion.isCompatibleOrNewer(actual, minimum)` — `actual ≥ minimum` by `(major, minor)`.
|
- `BackendVersion.isCompatibleOrNewer(actual, minimum)` — `actual ≥ minimum` by `(major, minor, patch)`.
|
||||||
- `BackendVersion.extractVersionFromFooter(footer)` — fallback parse from `customFooter`.
|
- `BackendVersion.extractVersionFromFooter(footer)` — fallback parse from `customFooter`.
|
||||||
|
|
||||||
The detected server version is exposed via `ConfigRepository.detectedBackendVersion`
|
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.
|
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.
|
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -47,26 +47,31 @@ object BackendVersion {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether two versions are compatible.
|
* Checks whether two versions are compatible.
|
||||||
* A mismatch in major or minor version is considered incompatible.
|
* Any mismatch in major, minor, or patch is considered incompatible.
|
||||||
* Patch differences are ignored (considered compatible).
|
|
||||||
*
|
*
|
||||||
* @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).
|
* Returns true if either version cannot be parsed (fail-open).
|
||||||
*/
|
*/
|
||||||
fun isCompatible(supported: String, actual: String): Boolean {
|
fun isCompatible(supported: String, actual: String): Boolean {
|
||||||
val supportedVersion = parse(supported) ?: return true
|
val supportedVersion = parse(supported) ?: return true
|
||||||
val actualVersion = parse(actual) ?: return true
|
val actualVersion = parse(actual) ?: return true
|
||||||
return supportedVersion.major == actualVersion.major &&
|
return supportedVersion == actualVersion
|
||||||
supportedVersion.minor == actualVersion.minor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether [actual] is greater than or equal to [minimum] (feature-gate check).
|
* 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
|
* Use this when branching on whether a backend feature was introduced in a
|
||||||
* specific version. Patch differences are ignored. Returns true when [actual]
|
* specific version. Comparison is lexicographic over (major, minor, patch) —
|
||||||
* cannot be parsed (fail-open: assume feature is present). Returns false when
|
* patch is included because upstream sometimes ships features and breaking
|
||||||
* [minimum] cannot be parsed (degenerate threshold).
|
* 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
|
* **Contract for callers:** null-check or explicitly handle the unknown-version
|
||||||
* case before invoking. The fail-open default here is intentional because in
|
* 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 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").
|
* @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 {
|
fun isCompatibleOrNewer(actual: String, minimum: String): Boolean {
|
||||||
val actualVersion = parse(actual) ?: return true
|
val actualVersion = parse(actual) ?: return true
|
||||||
|
|
@ -88,7 +93,10 @@ object BackendVersion {
|
||||||
if (actualVersion.major != minimumVersion.major) {
|
if (actualVersion.major != minimumVersion.major) {
|
||||||
return 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue