diff --git a/packages/inkstone/keys.test.mjs b/packages/inkstone/keys.test.mjs index 13a487f9..f22ef6ab 100644 --- a/packages/inkstone/keys.test.mjs +++ b/packages/inkstone/keys.test.mjs @@ -15,7 +15,15 @@ const now = (n) => new Date(Date.UTC(2026, 10, 1, 0, 0, n)).toISOString() function serveKeys(initial = []) { const keys = initial.map((k) => ({ ...k })) f.route("GET /v1/models", () => json({ object: "list", data: [] })) - f.route("GET /api/tokenplan/v1/keys", () => ok({ total: keys.length, items: keys })) + f.route("GET /api/tokenplan/v1/keys", (r) => { + // the platform as it is now: /keys rejects a bearer-only request + // (-10002) and rejects any query param (-15100) - the plugin must + // send the uaa-token cookie and no pagination query + const cookie = r.headers.get("cookie") ?? "" + if (!cookie.startsWith("uaa-token=") || cookie === "uaa-token=") return fail(-10002, "request is not authenticated") + if ([...r.query.keys()].length > 0) return fail(-15100, "API Key fetch failed, refresh page and retry") + return ok({ total: keys.length, items: keys }) + }) f.route("POST /api/tokenplan/v1/keys", (r) => { const name = r.json?.name keys.push({ id: `id-${keys.length + 1}`, name, status: "active", created_at: now(keys.length + 1) }) @@ -207,3 +215,34 @@ test("an idempotency clash retries with a FRESH name and returns the key string" expect(posted[1]).not.toBe(posted[0]) expect(posted[0]).toBe(refusedName) }) + +test("a refused new key is deleted by name - the cleanup listKeys carries no query", async () => { + f = fakeInkStone() + const keys = [{ id: "id-manual", name: "manual", status: "active", created_at: now(0) }] + // the probe always refuses the new key, so ensureKey falls into its + // 401-cleanup path, which lists the keys to find the one it just made + f.route("GET /v1/models", () => json({ error: "refused" }, 401)) + f.route("GET /api/tokenplan/v1/keys", (r) => { + // the platform as it is now: /keys rejects a bearer-only request + // (-10002), and rejects any query param (-15100). With this fake, + // putting the pagination query back on the cleanup listKeys call makes + // the list fail and the refused key survive - this test goes red. + if ((r.headers.get("cookie") ?? "") !== "uaa-token=[REDACTED_VALUE]") return fail(-10002, "request is not authenticated") + if ([...r.query.keys()].length > 0) return fail(-15100, "API Key fetch failed, refresh page and retry") + return ok({ total: keys.length, items: keys }) + }) + f.route("POST /api/tokenplan/v1/keys", (r) => { + keys.push({ id: "id-new", name: r.json?.name, status: "active", created_at: now(keys.length + 1) }) + return ok({ key: "sk-refused" }) + }) + f.route("DELETE /api/tokenplan/v1/keys/id-new", (r) => { + const i = keys.findIndex((k) => k.id === "id-new") + if (i >= 0) keys.splice(i, 1) + return ok({}) + }) + // probeDelays: [0] skips probeWithRetry's real backoff (0+1.5+3+4.5+6s) + // so the refusal is final immediately and this test stays fast + await expect(ensureKey("[REDACTED_VALUE]", "u", { create: true, probeDelays: [0] })).rejects.toMatchObject({ status: 401 }) + expect(keys.some((k) => k.id === "id-new")).toBe(false) + expect(keys).toHaveLength(1) +}) diff --git a/packages/inkstone/lib/keys.mjs b/packages/inkstone/lib/keys.mjs index de401138..63b1b088 100644 --- a/packages/inkstone/lib/keys.mjs +++ b/packages/inkstone/lib/keys.mjs @@ -39,8 +39,7 @@ const at = (value) => { } /** 新 Key 生效前网关可能要几秒:给几次机会,别把"还没生效"当成"被拒绝"。 */ -export async function probeWithRetry(apiKey, log) { - const delays = [0, 1500, 3000, 4500, 6000] +export async function probeWithRetry(apiKey, log, delays = [0, 1500, 3000, 4500, 6000]) { let last = null for (let i = 0; i < delays.length; i++) { if (delays[i]) await new Promise((r) => setTimeout(r, delays[i])) @@ -121,7 +120,7 @@ export async function pruneKeys(token, username, { freeSlots = 0, keep = 2, prot * - 没粘且允许创建 → 数一下,满了先清理(可选),再创建并探活。 * 抛错时带 `code`:`key_limit`(满了且没开清理)、`refused`(Key 被平台拒)。 */ -export async function ensureKey(token, username, { apiKey = "", create = true, prune = false, log } = {}) { +export async function ensureKey(token, username, { apiKey = "", create = true, prune = false, log, probeDelays } = {}) { const pasted = String(apiKey ?? "").trim() if (pasted) { await probeWithRetry(pasted, log) @@ -203,7 +202,7 @@ export async function ensureKey(token, username, { apiKey = "", create = true, p ) } try { - await probeWithRetry(created, log) + await probeWithRetry(created, log, probeDelays) } catch (e) { if (e?.status === 401 || e?.status === 403) { // 建出来却被拒:删掉这把,别给账号留垃圾。 diff --git a/packages/inkstone/package.json b/packages/inkstone/package.json index f8851029..e85226fb 100644 --- a/packages/inkstone/package.json +++ b/packages/inkstone/package.json @@ -1,6 +1,6 @@ { "name": "@magpie-community/opencode-inkstone-auth", - "version": "0.1.3", + "version": "0.1.4", "description": "Intern InkStone (书生·端砚) TokenPlan in OpenCode and magpie: one console sign-in gives the ink-credit balance, the 5-hour and 7-day windows and the credit packs, and (with an API key) the plan's models for chat", "type": "module", "main": "./opencode.mjs",