From 378bb7008d8a3ebe8073275d3f58459230b514f1 Mon Sep 17 00:00:00 2001 From: simonsmh Date: Fri, 16 Oct 2026 00:26:35 +0800 Subject: [PATCH] fix: refresh COSY version before signing the catalog request The fire-and-forget refresh ran only after the model-list request had already been signed, so a catalog fetched right after a new Qoder CLI release was signed with the stale identity, returned the reduced list, and poisoned the model cache for its full TTL (Codex P1 on #35). Await the throttled refresh at the top of updateQoderModelsCache, before buildAuthHeaders signs the request, and drop the VITEST skip: the fixture stub in catalog-cache tests is now URL-aware so the releases call stays hermetic. Adds a regression test asserting the releases fetch precedes the catalog fetch and the catalog request is signed with the freshly fetched version. --- src/__tests__/catalog-cache.test.ts | 49 ++++++++++++++++++++++++++++- src/catalog.ts | 18 +++++------ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/__tests__/catalog-cache.test.ts b/src/__tests__/catalog-cache.test.ts index b3240db..07a2026 100644 --- a/src/__tests__/catalog-cache.test.ts +++ b/src/__tests__/catalog-cache.test.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearQoderModelsMemCache, getCachedModelConfig, getCachedModels, updateQoderModelsCache } from "../catalog.js"; +import { clearCosyVersionMemCache } from "../cosy-version.js"; import { loadLiveFixture, responseFromFixture } from "./live-fixture.js"; function testHome(): string { @@ -37,12 +38,58 @@ afterEach(() => { }); describe("Qoder model cache", () => { + it("refreshes the COSY version before signing the catalog request", async () => { + const cosyCachePath = join(testHome(), ".pi", "agent", "qoder-cosy-version-cache.json"); + rmSync(cosyCachePath, { force: true }); + clearCosyVersionMemCache(); + + const calls: string[] = []; + let catalogHeaders: Record | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown, init?: { headers?: Record }) => { + if (String(url).includes("repo.qoder.com")) { + calls.push("releases"); + return new Response(JSON.stringify([{ tag_name: "1.1.99", draft: false, prerelease: false }]), { + status: 200, + }); + } + calls.push("catalog"); + catalogHeaders = init?.headers; + return new Response(JSON.stringify({ chat: [{ key: "lite", enable: true, display_name: "Lite" }] }), { + status: 200, + }); + }), + ); + + try { + await updateQoderModelsCache("access-token", "user-id", "Test User", "test@example.com", "global"); + + // The catalog request must be signed with the version the refresh + // just fetched (1.1.99, ahead of the bundled fallback), not the + // stale identity; otherwise the gateway returns the reduced list. + expect(calls).toEqual(["releases", "catalog"]); + expect(catalogHeaders?.["Cosy-Version"]).toBe("1.1.99"); + expect(getCachedModels("global").map((model) => model.id)).toEqual(["Lite"]); + } finally { + rmSync(cosyCachePath, { force: true }); + clearCosyVersionMemCache(); + } + }); + it.each([ ["global", ["Lite", "GLM5.2"]], ["cn", ["Qwen3.7Plus"]], ] as const)("maps the %s recorded-format catalog to friendly picker ids", async (region, expectedIds) => { const interaction = loadLiveFixture(region).interactions.modelList; - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(responseFromFixture(interaction))); + // URL-aware: updateQoderModelsCache first refreshes the COSY version + // from repo.qoder.com; that call must not consume the catalog fixture. + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown) => + String(url).includes("repo.qoder.com") ? new Response("[]", { status: 200 }) : responseFromFixture(interaction), + ), + ); await updateQoderModelsCache("access-token", "user-id", "Test User", "test@example.com", region); diff --git a/src/catalog.ts b/src/catalog.ts index 86f155c..77c0928 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -629,6 +629,14 @@ export async function updateQoderModelsCache( ): Promise { const modelListURL = getQoderModelListURL(mode); try { + // Refresh the dynamic COSY identity BEFORE signing this request: the + // gateway gates the catalog on the client's COSY version, so a catalog + // request signed with a stale identity returns the reduced list, which + // is then cached for its full TTL and registered for the whole session. + // The refresh is throttled to once a day and never throws; when it is + // not due it returns immediately. + await refreshGatewayCosyVersion(); + const headers = buildAuthHeaders(null, modelListURL, { userID, authToken, @@ -702,15 +710,5 @@ 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 {} }