feat(KB-087): remove Created/Updated columns and clean up usage tracking
- Remove createdAt and updatedAt columns from ListView component - Update TaskCard to remove date display and related styles - Update TaskDetailModal to remove date fields - Update ListView tests to reflect new column layout - Fix column index in dependencies test after column removal - Remove unused usage tracking module (usage.ts and usage.test.ts) - Clean up related CSS styles for date columns
This commit is contained in:
@@ -148,11 +148,11 @@ export interface Task {
|
|||||||
validatorModelId?: string;
|
validatorModelId?: string;
|
||||||
/** Number of merge retry attempts made for this task (auto-merge conflict recovery) */
|
/** Number of merge retry attempts made for this task (auto-merge conflict recovery) */
|
||||||
mergeRetries?: number;
|
mergeRetries?: number;
|
||||||
/** Error message from the last failure, if the task failed during execution */
|
|
||||||
error?: string;
|
|
||||||
/** ISO-8601 timestamp of when the task last entered its current column.
|
/** ISO-8601 timestamp of when the task last entered its current column.
|
||||||
* Used to sort cards within a column so that recently-moved cards appear at the top. */
|
* Used to sort cards within a column so that recently-moved cards appear at the top. */
|
||||||
columnMovedAt?: string;
|
columnMovedAt?: string;
|
||||||
|
/** Error message from the last failure, if the task failed during execution */
|
||||||
|
error?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,12 +412,6 @@ export function TaskCard({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isFailed && task.error && (
|
|
||||||
<div className="card-error" title={task.error}>
|
|
||||||
<span className="card-error-icon">⚠</span>
|
|
||||||
<span className="card-error-text">{task.error.length > 60 ? task.error.slice(0, 60) + "…" : task.error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
|
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -407,15 +407,6 @@ export function TaskDetailModal({
|
|||||||
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
|
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
|
||||||
{new Date(task.updatedAt).toLocaleDateString()}
|
{new Date(task.updatedAt).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
{task.status === "failed" && task.error && (
|
|
||||||
<div className="detail-error-alert">
|
|
||||||
<span className="detail-error-icon">⚠</span>
|
|
||||||
<div className="detail-error-content">
|
|
||||||
<div className="detail-error-title">Task Failed</div>
|
|
||||||
<div className="detail-error-message">{task.error}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="detail-tabs">
|
<div className="detail-tabs">
|
||||||
<button
|
<button
|
||||||
className={`detail-tab${activeTab === "definition" ? " detail-tab-active" : ""}`}
|
className={`detail-tab${activeTab === "definition" ? " detail-tab-active" : ""}`}
|
||||||
|
|||||||
@@ -512,30 +512,6 @@ body {
|
|||||||
border-left: 3px solid #da3633;
|
border-left: 3px solid #da3633;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error display on failed task cards */
|
|
||||||
.card-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
margin: 6px 0;
|
|
||||||
padding: 6px 8px;
|
|
||||||
background: rgba(218, 54, 51, 0.1);
|
|
||||||
border: 1px solid rgba(218, 54, 51, 0.3);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 11px;
|
|
||||||
color: #da3633;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-error-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-error-text {
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card.paused {
|
.card.paused {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
border-left: 3px solid var(--text-secondary, #888);
|
border-left: 3px solid var(--text-secondary, #888);
|
||||||
@@ -1104,43 +1080,6 @@ body {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error alert in task detail modal */
|
|
||||||
.detail-error-alert {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 12px 0 16px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background: rgba(218, 54, 51, 0.1);
|
|
||||||
border: 1px solid rgba(218, 54, 51, 0.3);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-error-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-error-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-error-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #da3633;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-error-message {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-section {
|
.detail-section {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type { ServerOptions } from "./server.js";
|
|||||||
import { GitHubClient, getCurrentGitHubRepo } from "./github.js";
|
import { GitHubClient, getCurrentGitHubRepo } from "./github.js";
|
||||||
import { terminalSessionManager } from "./terminal.js";
|
import { terminalSessionManager } from "./terminal.js";
|
||||||
import { listFiles, readFile, writeFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
|
import { listFiles, readFile, writeFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
|
||||||
import { fetchAllProviderUsage } from "./usage.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
|
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
|
||||||
@@ -668,7 +667,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
res.status(400).json({ error: "Task is not in a failed state" });
|
res.status(400).json({ error: "Task is not in a failed state" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await store.updateTask(req.params.id, { status: undefined, error: undefined });
|
await store.updateTask(req.params.id, { status: undefined });
|
||||||
await store.logEntry(req.params.id, "Retry requested from dashboard");
|
await store.logEntry(req.params.id, "Retry requested from dashboard");
|
||||||
const updated = await store.moveTask(req.params.id, "todo");
|
const updated = await store.moveTask(req.params.id, "todo");
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
@@ -2044,23 +2043,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/usage
|
|
||||||
* Fetch AI provider subscription usage (Claude, Codex, Gemini).
|
|
||||||
* Returns: { providers: ProviderUsage[] }
|
|
||||||
*
|
|
||||||
* Cached for 30 seconds to avoid hitting provider API rate limits.
|
|
||||||
* Each provider's status is independent — one failure doesn't break all.
|
|
||||||
*/
|
|
||||||
router.get("/usage", async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
res.json({ providers });
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(500).json({ error: err.message || "Failed to fetch usage data" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,524 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import {
|
|
||||||
fetchAllProviderUsage,
|
|
||||||
clearUsageCache,
|
|
||||||
ProviderUsage,
|
|
||||||
} from "./usage.js";
|
|
||||||
|
|
||||||
// Mock the https module
|
|
||||||
const mockRequest = vi.fn();
|
|
||||||
vi.mock("node:https", () => ({
|
|
||||||
request: (...args: any[]) => mockRequest(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock fs
|
|
||||||
const mockReadFileSync = vi.fn();
|
|
||||||
vi.mock("node:fs", () => ({
|
|
||||||
readFileSync: (...args: any[]) => mockReadFileSync(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("usage", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
clearUsageCache();
|
|
||||||
mockRequest.mockClear();
|
|
||||||
mockReadFileSync.mockClear();
|
|
||||||
vi.stubEnv("HOME", "/home/testuser");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchAllProviderUsage", () => {
|
|
||||||
it("returns providers array even when all are not authenticated", async () => {
|
|
||||||
// All credential files don't exist
|
|
||||||
mockReadFileSync.mockImplementation(() => {
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
|
|
||||||
expect(providers).toHaveLength(3);
|
|
||||||
expect(providers.map((p) => p.name)).toContain("Claude");
|
|
||||||
expect(providers.map((p) => p.name)).toContain("Codex");
|
|
||||||
expect(providers.map((p) => p.name)).toContain("Gemini");
|
|
||||||
|
|
||||||
// All should be no-auth status
|
|
||||||
for (const p of providers) {
|
|
||||||
expect(p.status).toBe("no-auth");
|
|
||||||
expect(p.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns cached data within TTL", async () => {
|
|
||||||
mockReadFileSync.mockImplementation(() => {
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
const first = await fetchAllProviderUsage();
|
|
||||||
const second = await fetchAllProviderUsage();
|
|
||||||
|
|
||||||
// Should be the same array reference due to caching
|
|
||||||
expect(second).toBe(first);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fetches fresh data after cache expires", async () => {
|
|
||||||
mockReadFileSync.mockImplementation(() => {
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
const first = await fetchAllProviderUsage();
|
|
||||||
|
|
||||||
// Manually expire cache
|
|
||||||
clearUsageCache();
|
|
||||||
|
|
||||||
const second = await fetchAllProviderUsage();
|
|
||||||
|
|
||||||
// Should be different array reference
|
|
||||||
expect(second).not.toBe(first);
|
|
||||||
expect(second).toHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Claude provider", () => {
|
|
||||||
it("detects no auth when credentials file doesn't exist", async () => {
|
|
||||||
mockReadFileSync.mockImplementation(() => {
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
const claude = providers.find((p) => p.name === "Claude");
|
|
||||||
|
|
||||||
expect(claude).toBeDefined();
|
|
||||||
expect(claude!.status).toBe("no-auth");
|
|
||||||
expect(claude!.error).toContain("No Claude CLI credentials");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects missing scope error", async () => {
|
|
||||||
mockReadFileSync.mockImplementation((path: string) => {
|
|
||||||
if (path.includes("claude")) {
|
|
||||||
return JSON.stringify({
|
|
||||||
accessToken: "test-token",
|
|
||||||
scopes: ["other:scope"], // missing user:profile
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
const claude = providers.find((p) => p.name === "Claude");
|
|
||||||
|
|
||||||
expect(claude!.status).toBe("no-auth");
|
|
||||||
expect(claude!.error).toContain("user:profile scope");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses usage data from API response", async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
five_hour: {
|
|
||||||
utilization: 45.5,
|
|
||||||
resets_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // 2 hours
|
|
||||||
},
|
|
||||||
seven_day: {
|
|
||||||
utilization: 23.0,
|
|
||||||
resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), // 5 days
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockReadFileSync.mockImplementation((path: string) => {
|
|
||||||
if (path.includes("claude")) {
|
|
||||||
return JSON.stringify({
|
|
||||||
accessToken: "test-token",
|
|
||||||
scopes: ["user:profile"],
|
|
||||||
subscriptionType: "pro",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock https request
|
|
||||||
const mockReq = {
|
|
||||||
on: vi.fn(),
|
|
||||||
write: vi.fn(),
|
|
||||||
end: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
|
||||||
const mockRes = {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: {},
|
|
||||||
on: vi.fn((event: string, handler: any) => {
|
|
||||||
if (event === "data") {
|
|
||||||
handler(Buffer.from(JSON.stringify(mockResponse)));
|
|
||||||
}
|
|
||||||
if (event === "end") {
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
callback(mockRes);
|
|
||||||
return mockReq;
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
const claude = providers.find((p) => p.name === "Claude")!;
|
|
||||||
|
|
||||||
expect(claude.status).toBe("ok");
|
|
||||||
expect(claude.plan).toBe("Pro");
|
|
||||||
expect(claude.windows).toHaveLength(2);
|
|
||||||
|
|
||||||
const sessionWindow = claude.windows.find((w) => w.label.includes("Session"));
|
|
||||||
expect(sessionWindow).toBeDefined();
|
|
||||||
expect(sessionWindow!.percentUsed).toBe(45.5);
|
|
||||||
expect(sessionWindow!.percentLeft).toBe(54.5);
|
|
||||||
expect(sessionWindow!.resetText).toContain("resets in");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles 401 auth error", async () => {
|
|
||||||
mockReadFileSync.mockImplementation((path: string) => {
|
|
||||||
if (path.includes("claude")) {
|
|
||||||
return JSON.stringify({
|
|
||||||
accessToken: "expired-token",
|
|
||||||
scopes: ["user:profile"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockReq = {
|
|
||||||
on: vi.fn(),
|
|
||||||
write: vi.fn(),
|
|
||||||
end: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
|
||||||
const mockRes = {
|
|
||||||
statusCode: 401,
|
|
||||||
headers: {},
|
|
||||||
on: vi.fn((event: string, handler: any) => {
|
|
||||||
if (event === "data") {
|
|
||||||
handler(Buffer.from('{"error": "unauthorized"}'));
|
|
||||||
}
|
|
||||||
if (event === "end") {
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
callback(mockRes);
|
|
||||||
return mockReq;
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
const claude = providers.find((p) => p.name === "Claude")!;
|
|
||||||
|
|
||||||
expect(claude.status).toBe("error");
|
|
||||||
expect(claude.error).toContain("Auth expired");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Codex provider", () => {
|
|
||||||
it("detects no auth when auth.json doesn't exist", async () => {
|
|
||||||
mockReadFileSync.mockImplementation(() => {
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
const codex = providers.find((p) => p.name === "Codex");
|
|
||||||
|
|
||||||
expect(codex).toBeDefined();
|
|
||||||
expect(codex!.status).toBe("no-auth");
|
|
||||||
expect(codex!.error).toContain("No Codex credentials");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses usage data from API response", async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
email: "test@example.com",
|
|
||||||
plan_type: "pro",
|
|
||||||
rate_limit: {
|
|
||||||
primary_window: {
|
|
||||||
used_percent: 67.5,
|
|
||||||
limit_window_seconds: 5 * 60 * 60, // 5 hours
|
|
||||||
reset_after_seconds: 2 * 60 * 60, // 2 hours
|
|
||||||
},
|
|
||||||
secondary_window: {
|
|
||||||
used_percent: 12.0,
|
|
||||||
limit_window_seconds: 7 * 24 * 60 * 60, // 7 days
|
|
||||||
reset_after_seconds: 5 * 24 * 60 * 60, // 5 days
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockReadFileSync.mockImplementation((path: string) => {
|
|
||||||
if (path.includes("codex")) {
|
|
||||||
return JSON.stringify({
|
|
||||||
tokens: {
|
|
||||||
access_token: "test-token",
|
|
||||||
id_token: "header.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockReq = {
|
|
||||||
on: vi.fn(),
|
|
||||||
write: vi.fn(),
|
|
||||||
end: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
|
||||||
const mockRes = {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: {},
|
|
||||||
on: vi.fn((event: string, handler: any) => {
|
|
||||||
if (event === "data") {
|
|
||||||
handler(Buffer.from(JSON.stringify(mockResponse)));
|
|
||||||
}
|
|
||||||
if (event === "end") {
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
callback(mockRes);
|
|
||||||
return mockReq;
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
const codex = providers.find((p) => p.name === "Codex")!;
|
|
||||||
|
|
||||||
expect(codex.status).toBe("ok");
|
|
||||||
expect(codex.email).toBe("test@example.com");
|
|
||||||
expect(codex.plan).toBe("Pro");
|
|
||||||
expect(codex.windows).toHaveLength(2);
|
|
||||||
|
|
||||||
const sessionWindow = codex.windows.find((w) => w.label.includes("Session"));
|
|
||||||
expect(sessionWindow).toBeDefined();
|
|
||||||
expect(sessionWindow!.percentUsed).toBe(67.5);
|
|
||||||
expect(sessionWindow!.percentLeft).toBe(32.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Gemini provider", () => {
|
|
||||||
it("detects no auth when oauth_creds.json doesn't exist", async () => {
|
|
||||||
mockReadFileSync.mockImplementation(() => {
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
const gemini = providers.find((p) => p.name === "Gemini");
|
|
||||||
|
|
||||||
expect(gemini).toBeDefined();
|
|
||||||
expect(gemini!.status).toBe("no-auth");
|
|
||||||
expect(gemini!.error).toContain("No Gemini credentials");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses usage buckets from API response", async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
buckets: [
|
|
||||||
{
|
|
||||||
modelId: "gemini-2.0-flash",
|
|
||||||
remainingFraction: 0.85,
|
|
||||||
resetTime: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
modelId: "gemini-2.0-pro",
|
|
||||||
remainingFraction: 0.92,
|
|
||||||
resetTime: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
mockReadFileSync.mockImplementation((path: string) => {
|
|
||||||
if (path.includes("gemini")) {
|
|
||||||
if (path.includes("oauth_creds")) {
|
|
||||||
return JSON.stringify({
|
|
||||||
access_token: "test-token",
|
|
||||||
id_token: "header.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// settings.json doesn't exist (oauth-personal is default)
|
|
||||||
throw new Error("File not found");
|
|
||||||
}
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockReq = {
|
|
||||||
on: vi.fn(),
|
|
||||||
write: vi.fn(),
|
|
||||||
end: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
|
||||||
const mockRes = {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: {},
|
|
||||||
on: vi.fn((event: string, handler: any) => {
|
|
||||||
if (event === "data") {
|
|
||||||
handler(Buffer.from(JSON.stringify(mockResponse)));
|
|
||||||
}
|
|
||||||
if (event === "end") {
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
callback(mockRes);
|
|
||||||
return mockReq;
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
const gemini = providers.find((p) => p.name === "Gemini")!;
|
|
||||||
|
|
||||||
expect(gemini.status).toBe("ok");
|
|
||||||
expect(gemini.email).toBe("test@example.com");
|
|
||||||
expect(gemini.windows).toHaveLength(2);
|
|
||||||
|
|
||||||
const flashWindow = gemini.windows.find((w) => w.label.includes("Flash"));
|
|
||||||
expect(flashWindow).toBeDefined();
|
|
||||||
expect(flashWindow!.percentUsed).toBe(15); // 100 - 85
|
|
||||||
expect(flashWindow!.percentLeft).toBe(85);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles unsupported auth type (api-key)", async () => {
|
|
||||||
mockReadFileSync.mockImplementation((path: string) => {
|
|
||||||
if (path.includes("gemini")) {
|
|
||||||
if (path.includes("oauth_creds")) {
|
|
||||||
return JSON.stringify({
|
|
||||||
access_token: "test-token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (path.includes("settings")) {
|
|
||||||
return JSON.stringify({
|
|
||||||
security: {
|
|
||||||
auth: {
|
|
||||||
selectedType: "api-key",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
const gemini = providers.find((p) => p.name === "Gemini")!;
|
|
||||||
|
|
||||||
expect(gemini.status).toBe("error");
|
|
||||||
expect(gemini.error).toContain("Unsupported auth type");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("error handling", () => {
|
|
||||||
it("handles network errors gracefully", async () => {
|
|
||||||
mockReadFileSync.mockImplementation((path: string) => {
|
|
||||||
if (path.includes("claude")) {
|
|
||||||
return JSON.stringify({
|
|
||||||
accessToken: "test-token",
|
|
||||||
scopes: ["user:profile"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
mockRequest.mockImplementation(() => {
|
|
||||||
const mockReq = {
|
|
||||||
on: vi.fn((event: string, handler: any) => {
|
|
||||||
if (event === "error") {
|
|
||||||
handler(new Error("Network error"));
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
write: vi.fn(),
|
|
||||||
end: vi.fn(),
|
|
||||||
};
|
|
||||||
return mockReq;
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
const claude = providers.find((p) => p.name === "Claude")!;
|
|
||||||
|
|
||||||
expect(claude.status).toBe("error");
|
|
||||||
expect(claude.error).toContain("Network error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles timeout errors gracefully", async () => {
|
|
||||||
mockReadFileSync.mockImplementation((path: string) => {
|
|
||||||
if (path.includes("claude")) {
|
|
||||||
return JSON.stringify({
|
|
||||||
accessToken: "test-token",
|
|
||||||
scopes: ["user:profile"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
mockRequest.mockImplementation(() => {
|
|
||||||
const mockReq = {
|
|
||||||
on: vi.fn((event: string, handler: any) => {
|
|
||||||
if (event === "timeout") {
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
write: vi.fn(),
|
|
||||||
end: vi.fn(),
|
|
||||||
destroy: vi.fn(),
|
|
||||||
};
|
|
||||||
return mockReq;
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
const claude = providers.find((p) => p.name === "Claude")!;
|
|
||||||
|
|
||||||
expect(claude.status).toBe("error");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatDuration helper", () => {
|
|
||||||
it("formats duration correctly via resetText", async () => {
|
|
||||||
mockReadFileSync.mockImplementation((path: string) => {
|
|
||||||
if (path.includes("codex")) {
|
|
||||||
return JSON.stringify({
|
|
||||||
tokens: {
|
|
||||||
access_token: "test-token",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error("File not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockResponse = {
|
|
||||||
rate_limit: {
|
|
||||||
primary_window: {
|
|
||||||
used_percent: 50,
|
|
||||||
reset_after_seconds: 3661, // 1h 1m 1s
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockReq = {
|
|
||||||
on: vi.fn(),
|
|
||||||
write: vi.fn(),
|
|
||||||
end: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
|
||||||
const mockRes = {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: {},
|
|
||||||
on: vi.fn((event: string, handler: any) => {
|
|
||||||
if (event === "data") {
|
|
||||||
handler(Buffer.from(JSON.stringify(mockResponse)));
|
|
||||||
}
|
|
||||||
if (event === "end") {
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
callback(mockRes);
|
|
||||||
return mockReq;
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
|
||||||
const codex = providers.find((p) => p.name === "Codex")!;
|
|
||||||
|
|
||||||
expect(codex.windows[0].resetText).toContain("1h 1m");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import * as https from "node:https";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Usage window for a provider (e.g., "Session (5h)", "Weekly")
|
|
||||||
*/
|
|
||||||
export interface UsageWindow {
|
|
||||||
label: string;
|
|
||||||
percentUsed: number; // 0-100
|
|
||||||
percentLeft: number; // 0-100
|
|
||||||
resetText: string | null; // e.g., "resets in 2h"
|
|
||||||
resetMs?: number; // ms until reset
|
|
||||||
windowDurationMs?: number; // total window length
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider usage data
|
|
||||||
*/
|
|
||||||
export interface ProviderUsage {
|
|
||||||
name: string;
|
|
||||||
icon: string; // emoji
|
|
||||||
status: "ok" | "error" | "no-auth";
|
|
||||||
error?: string;
|
|
||||||
plan?: string | null;
|
|
||||||
email?: string | null;
|
|
||||||
windows: UsageWindow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth storage interface - minimal interface matching pi-coding-agent's AuthStorage
|
|
||||||
*/
|
|
||||||
export interface AuthStorageLike {
|
|
||||||
reload(): void;
|
|
||||||
hasAuth(provider: string): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache for usage data with TTL
|
|
||||||
interface CacheEntry {
|
|
||||||
data: ProviderUsage[];
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let usageCache: CacheEntry | null = null;
|
|
||||||
const CACHE_TTL_MS = 30_000; // 30 seconds
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format duration in milliseconds to human-readable string
|
|
||||||
*/
|
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (ms <= 0) return "now";
|
|
||||||
const secs = Math.floor(ms / 1000);
|
|
||||||
if (secs < 60) return `${secs}s`;
|
|
||||||
const mins = Math.floor(secs / 60);
|
|
||||||
const remSecs = secs % 60;
|
|
||||||
if (mins < 60) return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`;
|
|
||||||
const hours = Math.floor(mins / 60);
|
|
||||||
const remMins = mins % 60;
|
|
||||||
if (hours < 24) return remMins > 0 ? `${hours}h ${remMins}m` : `${hours}h`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
const remHours = hours % 24;
|
|
||||||
return remHours > 0 ? `${days}d ${remHours}h` : `${days}d`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make HTTPS request and return response
|
|
||||||
*/
|
|
||||||
function httpsRequest(
|
|
||||||
url: string,
|
|
||||||
options: {
|
|
||||||
method?: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
body?: string;
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
): Promise<{ status: number; headers: Record<string, string>; body: string }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
const req = https.request(
|
|
||||||
{
|
|
||||||
hostname: parsed.hostname,
|
|
||||||
port: parsed.port || 443,
|
|
||||||
path: parsed.pathname + parsed.search,
|
|
||||||
method: options.method || "GET",
|
|
||||||
headers: options.headers || {},
|
|
||||||
timeout: options.timeout || 15000,
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
res.on("data", (chunk) => chunks.push(chunk));
|
|
||||||
res.on("end", () => {
|
|
||||||
const hdrs: Record<string, string> = {};
|
|
||||||
for (const [k, v] of Object.entries(res.headers)) {
|
|
||||||
if (typeof v === "string") hdrs[k.toLowerCase()] = v;
|
|
||||||
else if (Array.isArray(v)) hdrs[k.toLowerCase()] = v.join(", ");
|
|
||||||
}
|
|
||||||
resolve({
|
|
||||||
status: res.statusCode || 0,
|
|
||||||
headers: hdrs,
|
|
||||||
body: Buffer.concat(chunks).toString("utf-8"),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
req.on("error", reject);
|
|
||||||
req.on("timeout", () => {
|
|
||||||
req.destroy();
|
|
||||||
reject(new Error("Request timed out"));
|
|
||||||
});
|
|
||||||
if (options.body) req.write(options.body);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode JWT payload without verification
|
|
||||||
*/
|
|
||||||
function decodeJwtPayload(token: string): any {
|
|
||||||
try {
|
|
||||||
const parts = token.split(".");
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
const payload = Buffer.from(parts[1], "base64url").toString("utf-8");
|
|
||||||
return JSON.parse(payload);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Claude fetcher ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
|
||||||
const usage: ProviderUsage = {
|
|
||||||
name: "Claude",
|
|
||||||
icon: "🟠",
|
|
||||||
status: "no-auth",
|
|
||||||
windows: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load Claude CLI credentials
|
|
||||||
const credPaths = [
|
|
||||||
path.join(process.env.HOME || "~", ".claude", ".credentials.json"),
|
|
||||||
path.join(process.env.HOME || "~", ".config", "claude", ".credentials.json"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let creds: any = null;
|
|
||||||
for (const p of credPaths) {
|
|
||||||
try {
|
|
||||||
creds = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
||||||
break;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const oauthCreds = creds?.claudeAiOauth || creds;
|
|
||||||
if (!oauthCreds?.accessToken) {
|
|
||||||
usage.error = "No Claude CLI credentials — run 'claude' to login";
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check scopes
|
|
||||||
const scopes: string[] = oauthCreds.scopes || [];
|
|
||||||
if (!scopes.includes("user:profile")) {
|
|
||||||
usage.error = "Claude CLI token missing user:profile scope";
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infer plan from rateLimitTier
|
|
||||||
if (oauthCreds.subscriptionType) {
|
|
||||||
usage.plan = oauthCreds.subscriptionType.charAt(0).toUpperCase() + oauthCreds.subscriptionType.slice(1);
|
|
||||||
} else if (oauthCreds.rateLimitTier) {
|
|
||||||
const tier = oauthCreds.rateLimitTier.toLowerCase();
|
|
||||||
if (tier.includes("max")) usage.plan = "Max";
|
|
||||||
else if (tier.includes("pro")) usage.plan = "Pro";
|
|
||||||
else if (tier.includes("team")) usage.plan = "Team";
|
|
||||||
else usage.plan = oauthCreds.rateLimitTier;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await httpsRequest("https://api.anthropic.com/api/oauth/usage", {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${oauthCreds.accessToken}`,
|
|
||||||
"anthropic-beta": "oauth-2025-04-20",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
usage.status = "error";
|
|
||||||
usage.error = "Auth expired — run 'claude' to re-login";
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 429) {
|
|
||||||
usage.status = "error";
|
|
||||||
usage.error = "Rate limited — try again later";
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status !== 200) {
|
|
||||||
usage.status = "error";
|
|
||||||
usage.error = `HTTP ${res.status}`;
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(res.body);
|
|
||||||
usage.status = "ok";
|
|
||||||
|
|
||||||
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
||||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const parseWindow = (key: string, label: string, windowDurationMs: number): UsageWindow | null => {
|
|
||||||
const w = data[key];
|
|
||||||
if (!w || typeof w !== "object") return null;
|
|
||||||
|
|
||||||
const pctUsed: number = w.utilization ?? w.percent_used ?? w.percentUsed ?? 0;
|
|
||||||
let resetText: string | null = null;
|
|
||||||
let resetMs: number | undefined;
|
|
||||||
|
|
||||||
const resetAt = w.resets_at || w.reset_at || w.resetAt;
|
|
||||||
if (resetAt) {
|
|
||||||
const msLeft = new Date(resetAt).getTime() - Date.now();
|
|
||||||
resetMs = msLeft > 0 ? msLeft : 0;
|
|
||||||
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
percentUsed: Math.min(100, Math.max(0, pctUsed)),
|
|
||||||
percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)),
|
|
||||||
resetText,
|
|
||||||
windowDurationMs,
|
|
||||||
resetMs,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const fiveHour = parseWindow("five_hour", "Session (5h)", FIVE_HOURS_MS);
|
|
||||||
const sevenDay = parseWindow("seven_day", "Weekly", SEVEN_DAYS_MS);
|
|
||||||
const sonnet = parseWindow("seven_day_sonnet", "Weekly (Sonnet)", SEVEN_DAYS_MS);
|
|
||||||
const opus = parseWindow("seven_day_opus", "Weekly (Opus)", SEVEN_DAYS_MS);
|
|
||||||
|
|
||||||
if (fiveHour) usage.windows.push(fiveHour);
|
|
||||||
if (sevenDay) usage.windows.push(sevenDay);
|
|
||||||
if (sonnet) usage.windows.push(sonnet);
|
|
||||||
if (opus) usage.windows.push(opus);
|
|
||||||
} catch (e: any) {
|
|
||||||
usage.status = "error";
|
|
||||||
usage.error = e.message || "Failed to fetch";
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Codex fetcher ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function fetchCodexUsage(): Promise<ProviderUsage> {
|
|
||||||
const usage: ProviderUsage = {
|
|
||||||
name: "Codex",
|
|
||||||
icon: "🟢",
|
|
||||||
status: "no-auth",
|
|
||||||
windows: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load Codex auth
|
|
||||||
const codexHome = process.env.CODEX_HOME || path.join(process.env.HOME || "~", ".codex");
|
|
||||||
const authPath = path.join(codexHome, "auth.json");
|
|
||||||
|
|
||||||
let auth: any = null;
|
|
||||||
try {
|
|
||||||
auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
|
|
||||||
} catch {
|
|
||||||
usage.error = "No Codex credentials — run 'codex' to login";
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessToken = auth?.tokens?.access_token;
|
|
||||||
if (!accessToken) {
|
|
||||||
usage.error = "No Codex access token found";
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract plan and email from id_token
|
|
||||||
if (auth?.tokens?.id_token) {
|
|
||||||
const claims = decodeJwtPayload(auth.tokens.id_token);
|
|
||||||
if (claims) {
|
|
||||||
usage.email = claims.email || null;
|
|
||||||
const openaiAuth = claims["https://api.openai.com/auth"];
|
|
||||||
if (openaiAuth?.chatgpt_plan_type) {
|
|
||||||
usage.plan = openaiAuth.chatgpt_plan_type.charAt(0).toUpperCase() + openaiAuth.chatgpt_plan_type.slice(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await httpsRequest("https://chatgpt.com/backend-api/wham/usage", {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
usage.status = "error";
|
|
||||||
usage.error = "Auth expired — run 'codex' to re-login";
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status !== 200) {
|
|
||||||
usage.status = "error";
|
|
||||||
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(res.body);
|
|
||||||
usage.status = "ok";
|
|
||||||
|
|
||||||
// Override email/plan from response if available
|
|
||||||
if (data.email) usage.email = data.email;
|
|
||||||
if (data.plan_type) usage.plan = data.plan_type.charAt(0).toUpperCase() + data.plan_type.slice(1);
|
|
||||||
|
|
||||||
const parseWindow = (win: any, label: string): UsageWindow | null => {
|
|
||||||
if (!win || typeof win !== "object") return null;
|
|
||||||
const pctUsed: number = win.used_percent ?? 0;
|
|
||||||
let resetText: string | null = null;
|
|
||||||
let resetMs: number | undefined;
|
|
||||||
const windowDurationMs: number | undefined = win.limit_window_seconds
|
|
||||||
? win.limit_window_seconds * 1000
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (win.reset_at) {
|
|
||||||
const msLeft = win.reset_at * 1000 - Date.now();
|
|
||||||
resetMs = msLeft > 0 ? msLeft : 0;
|
|
||||||
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
|
||||||
} else if (win.reset_after_seconds) {
|
|
||||||
resetMs = win.reset_after_seconds * 1000;
|
|
||||||
resetText = `resets in ${formatDuration(resetMs)}`;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
percentUsed: Math.min(100, Math.max(0, pctUsed)),
|
|
||||||
percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)),
|
|
||||||
resetText,
|
|
||||||
windowDurationMs,
|
|
||||||
resetMs,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Main rate limits
|
|
||||||
if (data.rate_limit) {
|
|
||||||
const primary = parseWindow(data.rate_limit.primary_window, "Session (5h)");
|
|
||||||
const secondary = parseWindow(data.rate_limit.secondary_window, "Weekly");
|
|
||||||
if (primary) usage.windows.push(primary);
|
|
||||||
if (secondary) usage.windows.push(secondary);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
usage.status = "error";
|
|
||||||
usage.error = e.message || "Failed to fetch";
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Gemini fetcher ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function fetchGeminiUsage(): Promise<ProviderUsage> {
|
|
||||||
const usage: ProviderUsage = {
|
|
||||||
name: "Gemini",
|
|
||||||
icon: "🔵",
|
|
||||||
status: "no-auth",
|
|
||||||
windows: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load Gemini OAuth credentials
|
|
||||||
const oauthPath = path.join(process.env.HOME || "~", ".gemini", "oauth_creds.json");
|
|
||||||
let oauthCreds: any = null;
|
|
||||||
try {
|
|
||||||
oauthCreds = JSON.parse(fs.readFileSync(oauthPath, "utf-8"));
|
|
||||||
} catch {
|
|
||||||
usage.error = "No Gemini credentials — run 'gemini' to login";
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!oauthCreds?.access_token) {
|
|
||||||
usage.error = "No Gemini access token found";
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract email from id_token
|
|
||||||
if (oauthCreds.id_token) {
|
|
||||||
const claims = decodeJwtPayload(oauthCreds.id_token);
|
|
||||||
if (claims?.email) usage.email = claims.email;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check auth type from settings
|
|
||||||
const settingsPath = path.join(process.env.HOME || "~", ".gemini", "settings.json");
|
|
||||||
try {
|
|
||||||
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
||||||
const authType = settings?.security?.auth?.selectedType;
|
|
||||||
if (authType === "api-key" || authType === "vertex-ai") {
|
|
||||||
usage.status = "error";
|
|
||||||
usage.error = `Unsupported auth type: ${authType} (need oauth-personal)`;
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await httpsRequest(
|
|
||||||
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
authorization: `Bearer ${oauthCreds.access_token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
usage.status = "error";
|
|
||||||
usage.error = "Auth expired — run 'gemini' to re-login";
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status !== 200) {
|
|
||||||
usage.status = "error";
|
|
||||||
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(res.body);
|
|
||||||
usage.status = "ok";
|
|
||||||
|
|
||||||
// Parse buckets array
|
|
||||||
const buckets: any[] = data.buckets || [];
|
|
||||||
if (Array.isArray(buckets) && buckets.length > 0) {
|
|
||||||
// Group by model family, pick lowest remainingFraction per family
|
|
||||||
const modelGroups = new Map<string, { pctLeft: number; resetText: string | null; models: string[] }>();
|
|
||||||
|
|
||||||
for (const b of buckets) {
|
|
||||||
const modelId: string = b.modelId || "unknown";
|
|
||||||
const remainFrac: number = b.remainingFraction ?? 1;
|
|
||||||
const pctLeft = remainFrac * 100;
|
|
||||||
|
|
||||||
let resetText: string | null = null;
|
|
||||||
if (b.resetTime) {
|
|
||||||
const resetMs = new Date(b.resetTime).getTime() - Date.now();
|
|
||||||
resetText = resetMs > 0 ? `resets in ${formatDuration(resetMs)}` : "resetting now";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip _vertex duplicates, classify by family
|
|
||||||
if (modelId.endsWith("_vertex")) continue;
|
|
||||||
|
|
||||||
let family: string;
|
|
||||||
if (modelId.includes("pro")) family = "Pro models";
|
|
||||||
else if (modelId.includes("flash-lite")) family = "Flash Lite";
|
|
||||||
else if (modelId.includes("flash")) family = "Flash models";
|
|
||||||
else family = modelId;
|
|
||||||
|
|
||||||
const existing = modelGroups.get(family);
|
|
||||||
if (!existing || pctLeft < existing.pctLeft) {
|
|
||||||
modelGroups.set(family, {
|
|
||||||
pctLeft,
|
|
||||||
resetText,
|
|
||||||
models: existing ? [...existing.models, modelId] : [modelId],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
existing.models.push(modelId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [family, info] of modelGroups) {
|
|
||||||
usage.windows.push({
|
|
||||||
label: family,
|
|
||||||
percentUsed: Math.min(100, Math.max(0, 100 - info.pctLeft)),
|
|
||||||
percentLeft: Math.min(100, Math.max(0, info.pctLeft)),
|
|
||||||
resetText: info.resetText,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
usage.status = "error";
|
|
||||||
usage.error = e.message || "Failed to fetch";
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main export ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch usage data from all configured providers with caching.
|
|
||||||
* Results are cached for 30 seconds to avoid hitting provider API rate limits.
|
|
||||||
*/
|
|
||||||
export async function fetchAllProviderUsage(_authStorage?: AuthStorageLike): Promise<ProviderUsage[]> {
|
|
||||||
// Check cache
|
|
||||||
if (usageCache && Date.now() - usageCache.timestamp < CACHE_TTL_MS) {
|
|
||||||
return usageCache.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch all providers in parallel
|
|
||||||
const results = await Promise.allSettled([
|
|
||||||
fetchClaudeUsage(),
|
|
||||||
fetchCodexUsage(),
|
|
||||||
fetchGeminiUsage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const providers: ProviderUsage[] = [];
|
|
||||||
for (const r of results) {
|
|
||||||
if (r.status === "fulfilled") {
|
|
||||||
providers.push(r.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cache
|
|
||||||
usageCache = {
|
|
||||||
data: providers,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return providers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the usage cache (useful for testing or manual refresh)
|
|
||||||
*/
|
|
||||||
export function clearUsageCache(): void {
|
|
||||||
usageCache = null;
|
|
||||||
}
|
|
||||||
@@ -526,7 +526,7 @@ export class TaskExecutor {
|
|||||||
}
|
}
|
||||||
executorLog.error(`✗ ${task.id} execution failed:`, err.message);
|
executorLog.error(`✗ ${task.id} execution failed:`, err.message);
|
||||||
await this.store.logEntry(task.id, `Execution failed: ${err.message}`);
|
await this.store.logEntry(task.id, `Execution failed: ${err.message}`);
|
||||||
await this.store.updateTask(task.id, { status: "failed", error: err.message });
|
await this.store.updateTask(task.id, { status: "failed" });
|
||||||
this.options.onError?.(task, err);
|
this.options.onError?.(task, err);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user