Skip to content
Draft
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
30 changes: 30 additions & 0 deletions copilotj/server/protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# SPDX-FileCopyrightText: Copyright contributors to the CopilotJ project.
#
# SPDX-License-Identifier: Apache-2.0

"""Frontend <-> server protocol versioning (issue #68).

The protocol version uses ``MAJOR.MINOR`` semantics, declared as two separate
integers (so the bump intent is explicit) and concatenated into the exposed
``API_VERSION`` string:

- bump ``API_VERSION_MAJOR`` on any breaking protocol change (route shapes,
NDJSON event contracts, required field removal/rename, etc.);
- bump ``API_VERSION_MINOR`` on backward-compatible additions (new optional
fields/endpoints).

The combined ``API_VERSION`` string MUST be kept in sync with
``web/src/apis/version.ts`` ``API_VERSION``.

Examples:
>>> API_VERSION == f"{API_VERSION_MAJOR}.{API_VERSION_MINOR}"
True
>>> API_VERSION
'1.0'
"""

__all__ = ["API_VERSION", "API_VERSION_MAJOR", "API_VERSION_MINOR"]

API_VERSION_MAJOR = 1
API_VERSION_MINOR = 0
API_VERSION = f"{API_VERSION_MAJOR}.{API_VERSION_MINOR}"
11 changes: 11 additions & 0 deletions copilotj/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from copilotj.core.kb import ensure_faiss_index_async
from copilotj.core.lifecycle import run_cleanup
from copilotj.server.bridge import Bridge
from copilotj.server.protocol import API_VERSION
from copilotj.server.threads import Threads

__all__ = ["Server"]
Expand Down Expand Up @@ -78,6 +79,7 @@ def _create_app(self) -> web.Application:

r = app.router
r.add_get("/api/ping", _on_ping)
r.add_get("/api/version", _on_version)
r.add_get("/api/config", self._on_config)
r.add_get("/api/model/capabilities", self._on_model_capabilities)
r.add_get("/api/models", self._on_list_models)
Expand Down Expand Up @@ -253,5 +255,14 @@ async def _on_list_models(self, request: web.Request) -> web.Response:
return web.json_response({"providers": {r["provider"]: r for r in results}})


async def _on_version(request: web.Request) -> web.Response:
"""Report the frontend/server protocol version (issue #68).

The frontend compares this MAJOR against its baked-in ``API_VERSION`` and
warns the user when they differ (self-hosted server too old/new).
"""
return web.json_response({"api_version": API_VERSION})


async def _on_ping(request: web.Request) -> web.Response:
return web.Response(text="pong")
1 change: 1 addition & 0 deletions web/src/apis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@

export * from "./threads";
export * from "./models";
export * from "./version";
46 changes: 46 additions & 0 deletions web/src/apis/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* SPDX-FileCopyrightText: Copyright contributors to the CopilotJ project.
*
* SPDX-License-Identifier: Apache-2.0
*/

import { getBaseUrl } from "./base";

/**
* Protocol version this frontend was built against.
*
* MUST mirror the backend `copilotj/server/protocol.py` `API_VERSION`. Only the
* MAJOR component decides compatibility (see {@link parseApiMajor}).
*/
export const API_VERSION = "1.0";

export interface ServerVersion {
api_version: string;
}

/** Fetch the server's protocol version via `GET /api/version`. */
export async function getServerVersion(): Promise<ServerVersion> {
const url = `${getBaseUrl()}/version`;
const response = await fetch(url, {
method: "GET",
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}

/** Parse the MAJOR component of a "MAJOR.MINOR" version string. */
export function parseApiMajor(version: string | null | undefined): number | null {
if (!version) return null;
const match = /^(\d+)/.exec(version.trim());
return match ? Number(match[1]) : null;
}

/** Compatible iff the server's MAJOR matches this frontend's MAJOR. */
export function isApiVersionCompatible(serverVersion: string | null | undefined): boolean {
const server = parseApiMajor(serverVersion);
const client = parseApiMajor(API_VERSION);
return server !== null && client !== null && server === client;
}
42 changes: 42 additions & 0 deletions web/src/components/ChatboxHello.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ SPDX-License-Identifier: Apache-2.0
import { computed } from "vue";
import Logo from "./Logo.vue";
import { isApiBaseConfigurable } from "../apis/base";
import { API_VERSION } from "../apis";
import { useSettings, useSystemState } from "../store";

const settings = useSettings();
Expand All @@ -17,6 +18,13 @@ const showConnectionWarning = computed(
() => isApiBaseConfigurable && state.backendReachable === false && !state.connectionWarningDismissed,
);

const showVersionWarning = computed(
() =>
isApiBaseConfigurable &&
(state.apiVersionStatus === "incompatible" || state.apiVersionStatus === "unknown") &&
!state.apiVersionWarningDismissed,
);

const suggestions = [
"Find best segmentation method for this image",
"Segment this low signal-to-noise cell image",
Expand Down Expand Up @@ -77,6 +85,40 @@ function usePromptSuggestion(suggestion: string) {
</button>
</div>

<div
v-if="showVersionWarning"
class="mt-2 px-4 py-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 text-sm text-amber-800 dark:text-amber-300 max-w-md flex items-start justify-between gap-2"
>
<span v-if="state.apiVersionStatus === 'incompatible'">
Your server (API v{{ state.serverApiVersion ?? "?" }}) is not compatible with this version of CopilotJ (expects
v{{ API_VERSION }}). Please update your CopilotJ server, or switch to a compatible one in
<button
class="underline font-medium hover:text-amber-600 dark:hover:text-amber-200"
@click="state.showSettings = true"
>
Settings
</button>
.
</span>
<span v-else>
Couldn't verify your server's API version — it may be outdated. If chat isn't working, please update your
CopilotJ server or check the URL in
<button
class="underline font-medium hover:text-amber-600 dark:hover:text-amber-200"
@click="state.showSettings = true"
>
Settings
</button>
.
</span>
<button
class="shrink-0 font-bold hover:text-amber-600 dark:hover:text-amber-200"
@click="state.apiVersionWarningDismissed = true"
>
&times;
</button>
</div>

<div class="w-full mt-8 grid md:grid-cols-2 grid-cols-1 gap-4">
<div
v-for="suggestion in suggestions"
Expand Down
28 changes: 27 additions & 1 deletion web/src/components/SettingsConnection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ SPDX-License-Identifier: Apache-2.0
<script setup lang="ts">
import { ref, watch } from "vue";
import { setApiBaseUrl, testApiConnection } from "../apis/base";
import { getServerConfig } from "../apis";
import { getServerConfig, API_VERSION } from "../apis";
import type { ServerConfig } from "../apis";
import { useSystemState } from "../store";

const props = defineProps<{
apiBaseUrl: string;
Expand All @@ -21,6 +22,8 @@ const emit = defineEmits<{
(e: "update:serverConfig", value: ServerConfig | null): void;
}>();

const state = useSystemState();

const apiBaseUrl = ref(props.apiBaseUrl);

// Sync from prop (fixes pre-fill when parent sets value async)
Expand All @@ -44,6 +47,8 @@ async function testConnection() {
} catch {
// Server may not return config
}
// Verify frontend <-> server protocol version (issue #68).
await state.checkApiVersion();
}
}
</script>
Expand All @@ -70,6 +75,27 @@ async function testConnection() {
<p v-else-if="connectionStatus === 'fail'" class="text-sm text-red-600 dark:text-red-400 mt-1">
Could not reach the server. Please check the URL and try again.
</p>
<p
v-if="connectionStatus === 'ok' && state.apiVersionStatus === 'incompatible'"
class="text-sm text-amber-700 dark:text-amber-300 mt-1"
>
Server API version (v{{ state.serverApiVersion ?? "?" }}) is not compatible with this frontend (expects v{{
API_VERSION
}}). Please update your CopilotJ server.
</p>
<p
v-else-if="connectionStatus === 'ok' && state.apiVersionStatus === 'unknown'"
class="text-sm text-amber-700 dark:text-amber-300 mt-1"
>
Couldn't verify the server's API version — it may be outdated. Consider updating your CopilotJ server if chat
doesn't work.
</p>
<p
v-else-if="connectionStatus === 'ok' && state.apiVersionStatus === 'compatible'"
class="text-sm text-slate-400 dark:text-slate-500 mt-1"
>
Server API v{{ state.serverApiVersion }}
</p>
</FormItem>
</div>
</template>
22 changes: 22 additions & 0 deletions web/src/store/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { acceptHMRUpdate, defineStore } from "pinia";
import { ref } from "vue";
import { getBaseUrl, testApiConnection } from "../apis/base";
import { getServerVersion, isApiVersionCompatible } from "../apis/version";

export const useSystemState = defineStore("state", () => {
const showSettings = ref(false);
Expand All @@ -16,18 +17,39 @@ export const useSystemState = defineStore("state", () => {
const backendReachable = ref<boolean | null>(null);
const connectionWarningDismissed = ref(false);

// Frontend <-> server protocol version check (issue #68)
const serverApiVersion = ref<string | null>(null);
const apiVersionStatus = ref<"unknown" | "compatible" | "incompatible" | null>(null);
const apiVersionWarningDismissed = ref(false);

async function testBackendConnection() {
const rawUrl = getBaseUrl().replace(/\/api$/, "");
backendReachable.value = await testApiConnection(rawUrl);
}

async function checkApiVersion() {
try {
const { api_version } = await getServerVersion();
serverApiVersion.value = api_version;
apiVersionStatus.value = isApiVersionCompatible(api_version) ? "compatible" : "incompatible";
} catch {
// Endpoint missing (old server), network error, or unparseable → unknown.
serverApiVersion.value = null;
apiVersionStatus.value = "unknown";
}
}

return {
showManageAgents,
showSettings,
wizardMode,
backendReachable,
connectionWarningDismissed,
testBackendConnection,
serverApiVersion,
apiVersionStatus,
apiVersionWarningDismissed,
checkApiVersion,
};
});

Expand Down
2 changes: 2 additions & 0 deletions web/src/views/Chat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ onMounted(async () => {
}

state.testBackendConnection();
state.checkApiVersion();

// 1. If config store has a user-configured model, apply it
if (settings.model === null && config.data.defaultModel) {
Expand Down Expand Up @@ -81,6 +82,7 @@ const settingsRef = ref<InstanceType<typeof Settings> | null>(null);
function onSetupComplete() {
state.wizardMode = false;
state.testBackendConnection();
state.checkApiVersion();
}

function startNewThread() {
Expand Down
Loading