From f3e80ab5b59624ce76716c38365ac40f00c38b0b Mon Sep 17 00:00:00 2001 From: simonsmh Date: Thu, 15 Oct 2026 23:50:17 +0800 Subject: [PATCH] feat: track the latest Qoder CLI release for the COSY identity The gateway returns a reduced model catalog to clients whose COSY version lags the current Qoder CLI, so the hardcoded identity needed a manual bump PR for every CLI release (1.1.38 -> 1.1.71 -> ...). Resolve the version dynamically instead: cosy-version.ts reads the same releases feed the public changelog page uses (repo.qoder.com/repos/QoderAI/changelog-CLI/releases), caches the newest stable tag in ~/.pi/agent/qoder-cosy-version-cache.json, and refreshes at most once a day after a successful model-catalog update. Signing stays synchronous and network-free; any failure keeps the cached or bundled version, and the identity never downgrades. The bundled fallback moves to 1.1.72, the current CLI release. --- src/__tests__/cosy-version.test.ts | 129 +++++++++++++++++++++++ src/__tests__/cosy.test.ts | 6 +- src/catalog.ts | 11 ++ src/cosy-version.ts | 161 +++++++++++++++++++++++++++++ src/cosy.ts | 15 ++- 5 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/cosy-version.test.ts create mode 100644 src/cosy-version.ts diff --git a/src/__tests__/cosy-version.test.ts b/src/__tests__/cosy-version.test.ts new file mode 100644 index 0000000..a130319 --- /dev/null +++ b/src/__tests__/cosy-version.test.ts @@ -0,0 +1,129 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + BUNDLED_GATEWAY_COSY_VERSION, + clearCosyVersionMemCache, + compareCliVersions, + getGatewayCosyVersion, + isCliVersion, + refreshGatewayCosyVersion, +} from "../cosy-version.js"; + +const cachePath = join(process.env.HOME as string, ".pi", "agent", "qoder-cosy-version-cache.json"); + +function seedCache(data: unknown): void { + writeFileSync(cachePath, JSON.stringify(data), "utf-8"); + clearCosyVersionMemCache(); +} + +function stubReleasesFetch(releases: unknown, ok = true): ReturnType { + const fetchMock = vi.fn(async () => ({ + ok, + status: ok ? 200 : 500, + json: async () => releases, + })); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +beforeEach(() => { + rmSync(cachePath, { force: true }); + clearCosyVersionMemCache(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + rmSync(cachePath, { force: true }); + clearCosyVersionMemCache(); +}); + +describe("cosy version resolution", () => { + it("falls back to the bundled version when no cache exists", () => { + expect(getGatewayCosyVersion()).toBe(BUNDLED_GATEWAY_COSY_VERSION); + }); + + it("uses a cached version that is ahead of the bundled fallback", () => { + seedCache({ version: "1.1.99", checkedAt: Date.now() }); + expect(getGatewayCosyVersion()).toBe("1.1.99"); + }); + + it("never lets a stale cached version downgrade the bundled fallback", () => { + seedCache({ version: "1.1.1", checkedAt: Date.now() }); + expect(getGatewayCosyVersion()).toBe(BUNDLED_GATEWAY_COSY_VERSION); + }); + + it("ignores malformed cache content", () => { + writeFileSync(cachePath, "not json", "utf-8"); + clearCosyVersionMemCache(); + expect(getGatewayCosyVersion()).toBe(BUNDLED_GATEWAY_COSY_VERSION); + }); + + it("validates and compares x.y.z versions numerically", () => { + expect(isCliVersion("1.1.72")).toBe(true); + expect(isCliVersion("v1.1.72")).toBe(false); + expect(isCliVersion("1.1")).toBe(false); + expect(isCliVersion(172)).toBe(false); + expect(compareCliVersions("1.1.72", "1.1.71")).toBeGreaterThan(0); + expect(compareCliVersions("1.2.0", "1.1.99")).toBeGreaterThan(0); + expect(compareCliVersions("1.1.72", "1.1.72")).toBe(0); + }); +}); + +describe("refreshGatewayCosyVersion", () => { + it("caches the latest stable release tag and signs with it", async () => { + stubReleasesFetch([ + { tag_name: "1.1.73", draft: false, prerelease: false }, + { tag_name: "1.1.72", draft: false, prerelease: false }, + ]); + await refreshGatewayCosyVersion({ force: true }); + expect(getGatewayCosyVersion()).toBe("1.1.73"); + const saved = JSON.parse(readFileSync(cachePath, "utf8")) as { version?: string }; + expect(saved.version).toBe("1.1.73"); + }); + + it("skips drafts, prereleases, and malformed tags", async () => { + stubReleasesFetch([ + { tag_name: "1.1.99", draft: false, prerelease: true }, + { tag_name: "1.1.98", draft: true, prerelease: false }, + { tag_name: "nightly", draft: false, prerelease: false }, + { tag_name: "1.1.80", draft: false, prerelease: false }, + ]); + await refreshGatewayCosyVersion({ force: true }); + expect(getGatewayCosyVersion()).toBe("1.1.80"); + }); + + it("does not downgrade when the feed is behind the bundled fallback", async () => { + stubReleasesFetch([{ tag_name: "1.1.1", draft: false, prerelease: false }]); + await refreshGatewayCosyVersion({ force: true }); + expect(getGatewayCosyVersion()).toBe(BUNDLED_GATEWAY_COSY_VERSION); + }); + + it("keeps the bundled fallback when the fetch fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("offline"); + }), + ); + await refreshGatewayCosyVersion({ force: true }); + expect(getGatewayCosyVersion()).toBe(BUNDLED_GATEWAY_COSY_VERSION); + expect(existsSync(cachePath)).toBe(false); + }); + + it("keeps the bundled fallback on a non-OK response", async () => { + stubReleasesFetch([], false); + await refreshGatewayCosyVersion({ force: true }); + expect(getGatewayCosyVersion()).toBe(BUNDLED_GATEWAY_COSY_VERSION); + }); + + it("throttles repeat checks within the refresh interval", async () => { + const fetchMock = stubReleasesFetch([{ tag_name: "1.1.90", draft: false, prerelease: false }]); + await refreshGatewayCosyVersion({ force: true }); + expect(fetchMock).toHaveBeenCalledTimes(1); + await refreshGatewayCosyVersion(); + expect(fetchMock).toHaveBeenCalledTimes(1); + await refreshGatewayCosyVersion({ now: Date.now() + 25 * 60 * 60 * 1000 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/__tests__/cosy.test.ts b/src/__tests__/cosy.test.ts index 2a2c950..1bb9e6e 100644 --- a/src/__tests__/cosy.test.ts +++ b/src/__tests__/cosy.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { buildAuthHeaders } from "../cosy.js"; describe("COSY client identity", () => { - it("sends Cosy-Version and cosyVersion as current qodercli 1.1.71", () => { + it("sends Cosy-Version and cosyVersion as current qodercli 1.1.72", () => { const headers = buildAuthHeaders(null, "https://api3.qoder.sh/algo/api/v2/model/list", { userID: "user-1", authToken: "token-1", @@ -11,13 +11,13 @@ describe("COSY client identity", () => { email: "test@example.com", machineID: "machine-1", }); - expect(headers["Cosy-Version"]).toBe("1.1.71"); + expect(headers["Cosy-Version"]).toBe("1.1.72"); const auth = headers.Authorization; expect(auth.startsWith("Bearer COSY.")).toBe(true); const payloadB64 = auth.slice("Bearer COSY.".length).split(".")[0]; const payload = JSON.parse(Buffer.from(payloadB64, "base64").toString("utf8")) as { cosyVersion: string }; - expect(payload.cosyVersion).toBe("1.1.71"); + expect(payload.cosyVersion).toBe("1.1.72"); }); it("matches the legacy full-string Cosy signature for a known body Buffer", () => { diff --git a/src/catalog.ts b/src/catalog.ts index 631d981..86f155c 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { ThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai"; import { buildAuthHeaders } from "./cosy.js"; +import { refreshGatewayCosyVersion } from "./cosy-version.js"; import { getQoderBaseUrl, getQoderModelListURL, getQoderRegionConfig, type QoderMode } from "./region.js"; export const ZERO_COST = Object.freeze({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); @@ -701,5 +702,15 @@ export async function updateQoderModelsCache( }; writeParsedModelCache(mode, cacheData); + + // A successful catalog round-trip proves connectivity; use it to keep + // the dynamic COSY identity tracking the latest Qoder CLI release. + // Fire-and-forget (throttled to once a day, never throws): the fresh + // version applies to every request signed after this one. Skipped + // under vitest, where several suites replay fixture traffic through + // updateQoderModelsCache and must stay hermetic. + if (!process.env.VITEST) { + void refreshGatewayCosyVersion(); + } } catch {} } diff --git a/src/cosy-version.ts b/src/cosy-version.ts new file mode 100644 index 0000000..5825eae --- /dev/null +++ b/src/cosy-version.ts @@ -0,0 +1,161 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +/** + * Dynamic gateway COSY client identity. + * + * The gateway hands out a reduced model catalog to clients whose COSY + * version lags the current Qoder CLI, so the identity has to track the + * latest CLI release. The source of truth is the same releases feed the + * public changelog page (qoder.com/changelog?type=cli) reads: + * a GitHub-shaped API over the QoderAI/changelog-CLI repo, served via + * repo.qoder.com. The resolved version is cached on disk next to the + * model catalog cache and refreshed at most once a day; any failure + * (offline, bad payload, older tag) falls back to the bundled version + * below, so request signing never depends on this fetch succeeding. + */ +export const BUNDLED_GATEWAY_COSY_VERSION = "1.1.72"; +export const QODER_CLI_RELEASES_URL = "https://repo.qoder.com/repos/QoderAI/changelog-CLI/releases?per_page=5&page=1"; + +const CACHE_FILE = "qoder-cosy-version-cache.json"; +const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 5000; + +interface CosyVersionCache { + version?: string; + checkedAt?: number; +} + +interface CliRelease { + tag_name?: unknown; + draft?: unknown; + prerelease?: unknown; +} + +function getHomeDir(): string { + // Prefer process.env.HOME so vitest setup can isolate caches (same + // pattern as catalog.ts). + return process.env.HOME || process.env.USERPROFILE || homedir(); +} + +function getCosyVersionCachePath(): string { + return join(getHomeDir(), ".pi", "agent", CACHE_FILE); +} + +/** In-memory cache keyed by absolute cache path (HOME-safe across tests). */ +const cosyVersionMem = new Map(); + +/** Clear the process-memory version cache (also used by tests). */ +export function clearCosyVersionMemCache(): void { + cosyVersionMem.clear(); +} + +function readCosyVersionCache(): CosyVersionCache | null { + const cachePath = getCosyVersionCachePath(); + if (cosyVersionMem.has(cachePath)) { + return cosyVersionMem.get(cachePath) ?? null; + } + if (!existsSync(cachePath)) { + cosyVersionMem.set(cachePath, null); + return null; + } + try { + const data = JSON.parse(readFileSync(cachePath, "utf8")) as CosyVersionCache; + cosyVersionMem.set(cachePath, data); + return data; + } catch { + cosyVersionMem.set(cachePath, null); + return null; + } +} + +function writeCosyVersionCache(data: CosyVersionCache): void { + const cachePath = getCosyVersionCachePath(); + try { + mkdirSync(dirname(cachePath), { recursive: true }); + writeFileSync(cachePath, JSON.stringify(data, null, 2), "utf-8"); + } catch { + // A read-only home must not break requests; the in-memory copy below + // still serves this process. + } + cosyVersionMem.set(cachePath, data); +} + +export function isCliVersion(value: unknown): value is string { + return typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value); +} + +/** Numeric semver-ish comparison for plain x.y.z versions. */ +export function compareCliVersions(a: string, b: string): number { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) return pa[i] - pb[i]; + } + return 0; +} + +/** + * The COSY version to sign requests with right now: the newest cached + * CLI release when it is ahead of the bundled fallback, otherwise the + * bundled fallback. Synchronous and network-free; refresh happens in + * refreshGatewayCosyVersion(). + */ +export function getGatewayCosyVersion(): string { + const cached = readCosyVersionCache()?.version; + if (isCliVersion(cached) && compareCliVersions(cached, BUNDLED_GATEWAY_COSY_VERSION) >= 0) { + return cached; + } + return BUNDLED_GATEWAY_COSY_VERSION; +} + +let refreshInFlight: Promise | null = null; + +/** + * Fetch the latest Qoder CLI release tag and cache it when it is newer + * than the version currently in use. Throttled to one check per day via + * the cache's checkedAt stamp, deduplicated while in flight, and fully + * failure-tolerant: it never throws and never downgrades the identity. + */ +export async function refreshGatewayCosyVersion(options?: { force?: boolean; now?: number }): Promise { + const now = options?.now ?? Date.now(); + const checkedAt = readCosyVersionCache()?.checkedAt; + if (!options?.force && typeof checkedAt === "number" && now - checkedAt < REFRESH_INTERVAL_MS) { + return; + } + if (refreshInFlight) return refreshInFlight; + refreshInFlight = (async () => { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + let releases: unknown; + try { + const response = await fetch(QODER_CLI_RELEASES_URL, { + headers: { Accept: "application/vnd.github.v3+json" }, + signal: controller.signal, + }); + if (!response.ok) return; + releases = await response.json(); + } finally { + clearTimeout(timer); + } + if (!Array.isArray(releases)) return; + const latest = (releases as CliRelease[]).find( + (release) => release && !release.draft && !release.prerelease && isCliVersion(release.tag_name), + ); + const stampedAt = Date.now(); + if (latest && compareCliVersions(latest.tag_name as string, getGatewayCosyVersion()) > 0) { + writeCosyVersionCache({ version: latest.tag_name as string, checkedAt: stampedAt }); + } else { + writeCosyVersionCache({ version: readCosyVersionCache()?.version, checkedAt: stampedAt }); + } + } catch { + // Offline or a bad payload: keep signing with the cached/bundled + // version and try again on the next refresh window. + } finally { + refreshInFlight = null; + } + })(); + return refreshInFlight; +} diff --git a/src/cosy.ts b/src/cosy.ts index b52d97c..719df09 100644 --- a/src/cosy.ts +++ b/src/cosy.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { BUNDLED_GATEWAY_COSY_VERSION, getGatewayCosyVersion } from "./cosy-version.js"; const qoderRSAPublicKey = `-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc @@ -10,9 +11,12 @@ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc XcW+ML9FoCI6AOvOzwIDAQAB -----END PUBLIC KEY-----`; -// Keep the COSY client identity aligned with the current Qoder CLI catalog -// protocol. Older values cause the model endpoint to return a reduced list. -export const QODER_GATEWAY_COSY_VERSION = "1.1.71"; +// Bundled fallback for the COSY client identity. The effective value is +// resolved dynamically by cosy-version.ts, which tracks the latest Qoder +// CLI release (older identities make the model endpoint return a reduced +// list); this constant is what ships in the package and what requests fall +// back to when no newer release has been cached yet. +export const QODER_GATEWAY_COSY_VERSION = BUNDLED_GATEWAY_COSY_VERSION; export const QODER_OPENAPI_COSY_VERSION = "1.0.1"; export const QODER_CLIENT_TYPE = "5"; const QoderDataPolicy = "disagree"; @@ -126,12 +130,13 @@ export function buildAuthHeaders( const timestamp = Math.floor(Date.now() / 1000).toString(); const requestId = crypto.randomUUID(); + const cosyVersion = getGatewayCosyVersion(); const cosyPayload: CosyPayload = { version: "v1", requestId, info: infoB64, - cosyVersion: QODER_GATEWAY_COSY_VERSION, + cosyVersion, ideVersion: "", }; @@ -165,7 +170,7 @@ export function buildAuthHeaders( "Cosy-Key": cosyKey, "Cosy-User": creds.userID, "Cosy-Date": timestamp, - "Cosy-Version": QODER_GATEWAY_COSY_VERSION, + "Cosy-Version": cosyVersion, "Cosy-Machineid": machineID, "Cosy-Machinetoken": machineID, "Cosy-Machinetype": QoderMachineTypeMagic,