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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 4 additions & 4 deletions src/claude-quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.");
}
Expand Down Expand Up @@ -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",
Expand Down
37 changes: 31 additions & 6 deletions test/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -981,15 +983,22 @@ function mockClaudeManagement(t: TestContext, baseUrl: string, usage: (account:
};
t.mock.method(globalThis, "fetch", async (input: Parameters<typeof fetch>[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");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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", {
Expand All @@ -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<typeof fetch>[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();
});
Expand All @@ -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();
}
});
Expand Down
Loading