diff --git a/README.md b/README.md index b52bfd6..ba673fa 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,15 @@ Chat uses Pi's native API adapters and model metadata, with availability from yo ## Install -The extension targets Pi 1.3.0 and CLIProxyAPI 8.0.30, with Node.js 22.19 or newer. Run CLIProxyAPI with your upstream accounts configured before connecting Pi. The extension uses Pi's host-provided packages, adds no runtime dependencies, and needs no compilation. +The extension targets Pi 1.3.0 and CLIProxyAPI 8.0.32, with Node.js 22.19 or newer. Run CLIProxyAPI with your upstream accounts configured before connecting Pi. The extension uses Pi's host-provided packages, adds no runtime dependencies, and needs no compilation. If you installed `pi-cliproxy-native`, remove it before installing the renamed package. Both register the same `cliproxyapi` provider. ```sh -pi install npm:pi-cliproxyapi-native@0.2.0 +pi install npm:pi-cliproxyapi-native@0.2.1 ``` -For Git installation, use `pi install git:github.com/hawkff/pi-cliproxyapi-native@v0.2.0`. For a local checkout, use `pi install /path/to/pi-cliproxyapi-native`. +For Git installation, use `pi install git:github.com/hawkff/pi-cliproxyapi-native@v0.2.1`. For a local checkout, use `pi install /path/to/pi-cliproxyapi-native`. The default proxy address is `http://localhost:8317`. For another address, set the [connection](#connection) before logging in. Restart Pi, then run: @@ -218,7 +218,7 @@ Without a management key, Claude retains its SDK retry settings and response-bas Invalid management credentials, rejected account access, invalid usage data, or an exhausted allowance with an expired reset stop the quota wait with an explanation. These failures do not trigger Pi's quick retries. Extension-managed waits share one fixed seven-day deadline per provider call; a new cooldown cannot extend it. The extension checks the deadline after waking and before accepting available quota, including after machine sleep. It does not replay a request that has started streaming. -Management requests stay on the configured proxy and reject redirects. Account access tokens stay in CLIProxyAPI. The extension reads account metadata and proxies usage GETs; it does not reset quotas, change credentials, or clear proxy cooldowns. Management keys grant broader access than chat keys, so keep them private. Live lookup supports up to 100 active Claude subscription accounts. +Live quota lookup requires CLIProxyAPI's `/v8/management` API and does not fall back to legacy `/v0/management` routes. Management requests stay on the configured proxy and reject redirects. Account access tokens stay in CLIProxyAPI. The extension reads account metadata and proxies usage GETs; it does not reset quotas, change credentials, or clear proxy cooldowns. Management keys grant broader access than chat keys, so keep them private. Live lookup supports up to 100 active Claude subscription accounts. Explicit `api/` routes skip live Claude subscription lookup, even with a management key. `oauth/` routes keep the lookup for matching OAuth accounts. Retries keep the selected route's full model ID. diff --git a/package.json b/package.json index 7b28214..7aa7e2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-cliproxyapi-native", - "version": "0.2.0", + "version": "0.2.1", "description": "CLIProxyAPI chat discovery and image/video generation with Pi's native adapters", "publishConfig": { "access": "public" diff --git a/src/claude-quota.ts b/src/claude-quota.ts index 59dd30a..8780a55 100644 --- a/src/claude-quota.ts +++ b/src/claude-quota.ts @@ -15,7 +15,7 @@ async function managementRequest( ) { let response: Response; try { - response = await fetch(`${config.baseUrl}/v0/management/${path}`, { + response = await fetch(`${config.baseUrl}/v8/management/${path}`, { method: body ? "POST" : "GET", headers: { Authorization: `Bearer ${key}`, ...(body ? { "Content-Type": "application/json" } : {}) }, body: body ? JSON.stringify(body) : undefined, @@ -135,7 +135,7 @@ export async function fetchClaudeQuota( ); } const bounded = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(15000)]); - const listing = await managementRequest(config, key, "auth-files", bounded); + const listing = await managementRequest(config, key, "credentials", bounded); if (!isRecord(listing) || !Array.isArray(listing.files) || listing.files.length > 10000) { throw new Error("The management API returned an invalid account list."); } @@ -168,13 +168,13 @@ export async function fetchClaudeQuota( const models = await managementRequest( config, key, - `auth-files/models?name=${encodeURIComponent(account.name)}`, + `credentials/models?name=${encodeURIComponent(account.name)}`, bounded, ); if (!isRecord(models) || !Array.isArray(models.models)) throw new Error("The management API returned an invalid account catalog."); if (!models.models.some((model: unknown) => isRecord(model) && model.id === modelId)) return undefined; - const response = await managementRequest(config, key, "api-call", bounded, { + const response = await managementRequest(config, key, "requests/api-call", bounded, { auth_index: account.auth_index, method: "GET", url: "https://api.anthropic.com/api/oauth/usage", diff --git a/test/provider.test.ts b/test/provider.test.ts index 233f240..b1b094c 100644 --- a/test/provider.test.ts +++ b/test/provider.test.ts @@ -965,7 +965,9 @@ test("quota handling does not replay a stream after it starts", async (t) => { function mockClaudeManagement(t: TestContext, baseUrl: string, usage: (account: string) => unknown) { const original = globalThis.fetch; + const urls: string[] = []; const seen = { + urls, calls: 0, status: 200, modelIds: [known[0].id], @@ -981,15 +983,22 @@ function mockClaudeManagement(t: TestContext, baseUrl: string, usage: (account: }; t.mock.method(globalThis, "fetch", async (input: Parameters[0], init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : input); - if (!url.pathname.includes("/v0/management/")) return original(input, init); - assert.ok(url.href.startsWith(`${baseUrl}/v0/management/`)); + if (!url.pathname.includes("/management/")) return original(input, init); + seen.urls.push(url.href); + assert.ok(url.href.startsWith(`${baseUrl}/v8/management/`)); assert.equal(new Headers(init?.headers).get("authorization"), "Bearer fixture-management-key"); assert.equal(init?.redirect, "error"); assert.ok(init?.signal); - if (url.pathname.endsWith("/auth-files")) return Response.json({ files: seen.accounts }); - if (url.pathname.endsWith("/auth-files/models")) + if (url.href === `${baseUrl}/v8/management/credentials`) { + assert.equal(init?.method, "GET"); + return Response.json({ files: seen.accounts }); + } + if (url.pathname.endsWith("/credentials/models")) { + assert.equal(init?.method, "GET"); + assert.ok(seen.accounts.some((account) => account.name === url.searchParams.get("name"))); return Response.json({ models: seen.modelIds.map((id) => ({ id })) }); - assert.ok(url.pathname.endsWith("/api-call")); + } + assert.equal(url.href, `${baseUrl}/v8/management/requests/api-call`); assert.equal(init?.method, "POST"); const body = JSON.parse(String(init?.body)); assert.equal(body.method, "GET"); @@ -1025,6 +1034,19 @@ function claudeCooldown(res: ServerResponse) { ); } +test("live Claude quota uses v8 routes and encodes account names under a path prefix", async (t) => { + const config = parseConfig({ baseUrl: "https://proxy.example/gateway" }); + const seen = mockClaudeManagement(t, config.baseUrl, () => claudeUsage(Date.now() + 900000, 20)); + seen.accounts[0].name = "fixture +#.json"; + assert.deepEqual(await fetchClaudeQuota(config, "fixture-management-key", known[0].id), { state: "ready" }); + assert.deepEqual(seen.urls, [ + "https://proxy.example/gateway/v8/management/credentials", + "https://proxy.example/gateway/v8/management/credentials/models?name=fixture%20%2B%23.json", + "https://proxy.example/gateway/v8/management/requests/api-call", + ]); + assert.equal(seen.calls, 1); +}); + test("live Claude windows distinguish five-hour, weekly, and model-scoped exhaustion", () => { const now = Date.parse("2030-01-01T00:00:00Z"); const usage = claudeUsage(now + 900000); @@ -1314,6 +1336,7 @@ test("weekly exhaustion discovered during a wait stops before another chat reque test("management quota failures reject redirects and oversized bodies without exposing upstream content", async (t) => { for (const fixture of [ () => new Response("fixture-private-content", { status: 401 }), + () => new Response("fixture-private-content", { status: 404 }), () => new Response("fixture-private-content", { status: 503 }), () => new Response("fixture-private-content", { @@ -1323,8 +1346,9 @@ test("management quota failures reject redirects and oversized bodies without ex () => new Response("fixture-private-content"), () => new Response("x".repeat(2 * 1024 * 1024 + 1)), ]) { + const urls: string[] = []; t.mock.method(globalThis, "fetch", async (input: Parameters[0], init?: RequestInit) => { - assert.equal(String(input), "https://proxy.example/v0/management/auth-files"); + urls.push(String(input)); assert.equal(init?.redirect, "error"); return fixture(); }); @@ -1339,6 +1363,7 @@ test("management quota failures reject redirects and oversized bodies without ex !error.message.includes("fixture-private-content") && !error.message.includes("fixture-management-key"), ); + assert.deepEqual(urls, ["https://proxy.example/v8/management/credentials"]); t.mock.restoreAll(); } });