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
41 changes: 40 additions & 1 deletion packages/inkstone/keys.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand Down Expand Up @@ -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)
})
7 changes: 3 additions & 4 deletions packages/inkstone/lib/keys.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
// 建出来却被拒:删掉这把,别给账号留垃圾。
Expand Down
2 changes: 1 addition & 1 deletion packages/inkstone/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading