Skip to content
Merged
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
129 changes: 129 additions & 0 deletions src/__tests__/cosy-version.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn> {
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);
});
});
6 changes: 3 additions & 3 deletions src/__tests__/cosy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@ 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",
name: "Test",
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", () => {
Expand Down
11 changes: 11 additions & 0 deletions src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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();
Comment on lines +712 to +713

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh COSY version before requesting the gated catalog

When Qoder publishes a version newer than the bundled or cached one, this refresh starts only after the model-list request has already been signed with the stale identity and its reduced result has been cached. Because the call is fire-and-forget, startup then registers that reduced list immediately, while the cache is considered fresh for an hour; later requests may use the new identity, but the current session's registered models are not rebuilt. Await the throttled version refresh before constructing the catalog request so the request this feature is intended to fix uses the current identity.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #36. The catalog update now awaits the throttled COSY refresh before buildAuthHeaders signs the model-list request, so the request this feature exists for is the one that uses the fresh identity. The old fire-and-forget block (and its VITEST skip) is removed; the catalog fixture stub is URL-aware instead, and a new regression test pins the ordering and asserts the catalog request goes out with the freshly fetched Cosy-Version. Thanks for catching it.

}
} catch {}
}
161 changes: 161 additions & 0 deletions src/cosy-version.ts
Original file line number Diff line number Diff line change
@@ -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<string, CosyVersionCache | null>();

/** 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<void> | 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<void> {
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;
}
15 changes: 10 additions & 5 deletions src/cosy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
Expand Down Expand Up @@ -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: "",
};

Expand Down Expand Up @@ -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,
Expand Down
Loading