diff --git a/VERSION_GATES.md b/VERSION_GATES.md index 99d81b7..6961222 100644 --- a/VERSION_GATES.md +++ b/VERSION_GATES.md @@ -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. diff --git a/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/BackendVersion.kt b/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/BackendVersion.kt index cc2ac19..c93f290 100644 --- a/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/BackendVersion.kt +++ b/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/BackendVersion.kt @@ -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 } /** diff --git a/core/common/src/commonTest/kotlin/com/garfiec/librechat/core/common/BackendVersionTest.kt b/core/common/src/commonTest/kotlin/com/garfiec/librechat/core/common/BackendVersionTest.kt new file mode 100644 index 0000000..5d1c956 --- /dev/null +++ b/core/common/src/commonTest/kotlin/com/garfiec/librechat/core/common/BackendVersionTest.kt @@ -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 +}