mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
Keep usage logins visible and explain unavailable quota (#5876)
* fix(usage): keep existing logins visible and explain unavailable quota Credential discovery must preserve accounts whose login exists even when it has expired. Keep vendor failures on the discovered account and isolate discovery failures without inventing provider cards. * test(usage): align credential route tests with account discovery
This commit is contained in:
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { identify, fetchUsage } from "./usage.js";
|
||||
import { discover, fetchUsage } from "./usage.js";
|
||||
import type { UsageReport } from "@getpaseo/plugin/server/usage";
|
||||
|
||||
function mockFetch(handlers: Map<string, () => Response>): typeof fetch {
|
||||
@@ -65,14 +65,14 @@ describe("copilot usage source", () => {
|
||||
) {
|
||||
return {
|
||||
listUsage: async () => {
|
||||
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
|
||||
const report = await fetchFirst((url, init) => fetchApi(url, init));
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
providerId: "copilot",
|
||||
...report,
|
||||
error: report.error ?? null,
|
||||
planLabel: report.planLabel ?? null,
|
||||
error: report.status === "error" ? report.error : null,
|
||||
planLabel: report.status === "available" ? (report.planLabel ?? null) : null,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -112,17 +112,60 @@ describe("copilot usage source", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("identify returns a key when fetch finds copilot credentials", async () => {
|
||||
it("discovery returns a locator when fetch finds copilot credentials", async () => {
|
||||
const previous = process.env["COPILOT_TOKEN"];
|
||||
try {
|
||||
process.env["COPILOT_TOKEN"] = "fixture-token";
|
||||
let requested = false;
|
||||
await fetchUsage({}, async () => {
|
||||
await fetchFirst(async () => {
|
||||
requested = true;
|
||||
return new Response(null, { status: 401 });
|
||||
});
|
||||
expect(requested).toBe(true);
|
||||
expect(await identify()).toEqual({ key: "default" });
|
||||
expect(await discover()).toEqual([
|
||||
{ key: "default", input: { store: "env", locator: "COPILOT_TOKEN" } },
|
||||
]);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env["COPILOT_TOKEN"];
|
||||
else process.env["COPILOT_TOKEN"] = previous;
|
||||
}
|
||||
});
|
||||
|
||||
describe("account discovery", () => {
|
||||
it.each(["empty home", "unrelated files"])("returns no accounts for %s", async (scenario) => {
|
||||
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
||||
const directory = await mkdtemp(join(tmpdir(), "usage-empty-"));
|
||||
const original = { ...process.env };
|
||||
try {
|
||||
for (const key of Object.keys(process.env)) delete process.env[key];
|
||||
process.env.HOME = directory;
|
||||
process.env.USERPROFILE = directory;
|
||||
if (scenario === "unrelated files") await writeFile(join(directory, "unrelated.json"), "{}");
|
||||
expect(await discover()).toEqual([]);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) delete process.env[key];
|
||||
Object.assign(process.env, original);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function fetchFirst(fetchApi: typeof fetch) {
|
||||
const accounts = await discover();
|
||||
const account = accounts[0];
|
||||
if (!account) throw new Error("No configured account");
|
||||
return fetchUsage(account.input as Parameters<typeof fetchUsage>[0], fetchApi);
|
||||
}
|
||||
|
||||
it.each([401, 403])("reports an existing login rejected with HTTP %i", async (status) => {
|
||||
const previous = process.env["COPILOT_TOKEN"];
|
||||
try {
|
||||
process.env["COPILOT_TOKEN"] = "fixture-rejected-login";
|
||||
const report = await fetchUsage(
|
||||
{ store: "env", locator: "COPILOT_TOKEN" },
|
||||
async () => new Response(null, { status }),
|
||||
);
|
||||
expect(report).toEqual({ status: "unavailable", problem: { kind: "rejected", status } });
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env["COPILOT_TOKEN"];
|
||||
else process.env["COPILOT_TOKEN"] = previous;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { UsageInput } from "../shared/input.js";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
unavailableUsage,
|
||||
unavailable,
|
||||
type UsageAccount,
|
||||
type UsageReport,
|
||||
type UsageDetail,
|
||||
} from "@getpaseo/plugin/server/usage";
|
||||
@@ -19,39 +20,36 @@ const CopilotUsageResponseSchema = z.object({
|
||||
quota_reset_date: ApiOptionalStringSchema,
|
||||
});
|
||||
|
||||
async function readGithubCliToken(): Promise<string | null> {
|
||||
const candidates: string[] = [];
|
||||
if (process.env["APPDATA"]) {
|
||||
candidates.push(join(process.env["APPDATA"], "GitHub CLI", "hosts.yml"));
|
||||
async function readToken(input: UsageInput): Promise<string | undefined> {
|
||||
if (input.store === "env") return process.env[input.locator];
|
||||
try {
|
||||
const raw = await fs.readFile(input.locator, "utf8");
|
||||
return raw.match(/oauth_token:\s*["']?([a-zA-Z0-9_-]+)["']?/)?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
candidates.push(join(homedir(), ".config", "gh", "hosts.yml"));
|
||||
}
|
||||
|
||||
for (const path of candidates) {
|
||||
if (!existsSync(path)) continue;
|
||||
try {
|
||||
const raw = await fs.readFile(path, "utf8");
|
||||
const match = raw.match(/oauth_token:\s*["']?([a-zA-Z0-9_-]+)["']?/);
|
||||
if (match?.[1]) return match[1];
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
export async function discover(): Promise<UsageAccount[]> {
|
||||
const candidates: UsageInput[] = ["COPILOT_TOKEN", "GITHUB_TOKEN", "GITHUB_PAT"].map(
|
||||
(locator) => ({ store: "env", locator }),
|
||||
);
|
||||
if (process.env.APPDATA)
|
||||
candidates.push({
|
||||
store: "file",
|
||||
locator: join(process.env.APPDATA, "GitHub CLI", "hosts.yml"),
|
||||
});
|
||||
candidates.push({ store: "file", locator: join(homedir(), ".config", "gh", "hosts.yml") });
|
||||
for (const input of candidates) if (await readToken(input)) return [{ key: "default", input }];
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function fetchUsage(
|
||||
input: UsageInput,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
void input;
|
||||
|
||||
const token =
|
||||
process.env["COPILOT_TOKEN"] ||
|
||||
process.env["GITHUB_TOKEN"] ||
|
||||
process.env["GITHUB_PAT"] ||
|
||||
(await readGithubCliToken());
|
||||
|
||||
if (!token) return unavailableUsage();
|
||||
const token = await readToken(input);
|
||||
if (!token) throw new Error("Copilot login store no longer exists");
|
||||
|
||||
const res = await fetchApi("https://api.github.com/copilot_internal/user", {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
@@ -65,9 +63,9 @@ export async function fetchUsage(
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return unavailableUsage();
|
||||
}
|
||||
if (res.status === 401 || res.status === 403)
|
||||
return unavailable({ kind: "rejected", status: res.status });
|
||||
if (!res.ok) throw new Error(`Copilot usage API returned ${res.status}`);
|
||||
|
||||
const resp = CopilotUsageResponseSchema.parse(await res.json());
|
||||
const details: UsageDetail[] = resp.quota_reset_date
|
||||
@@ -82,12 +80,3 @@ export async function fetchUsage(
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
export async function identify() {
|
||||
const token =
|
||||
process.env["COPILOT_TOKEN"] ||
|
||||
process.env["GITHUB_TOKEN"] ||
|
||||
process.env["GITHUB_PAT"] ||
|
||||
(await readGithubCliToken());
|
||||
return token ? { key: "default" } : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user