refactor(quota): move provider quota logic into adapter layer, add unit tests
- Extract all Anthropic credential/API logic into claude-local/src/server/quota.ts - Extract all OpenAI/WHAM credential/API logic into codex-local/src/server/quota.ts - Add optional getQuotaWindows() to ServerAdapterModule in adapter-utils - Rewrite quota-windows.ts as a 29-line thin aggregator with zero provider knowledge - Wire getQuotaWindows into adapter registry for claude-local and codex-local - Add 47 unit tests covering toPercent, secondsToWindowLabel, WHAM normalization, readClaudeToken, readCodexToken, fetchClaudeQuota, fetchCodexQuota, fetchWithTimeout - Add 8 unit tests covering parseDateRange validation and byProvider pro-rata math Adding a third provider now requires only touching that provider's adapter.
This commit is contained in:
@@ -6,6 +6,14 @@ export {
|
||||
isClaudeMaxTurnsResult,
|
||||
isClaudeUnknownSessionError,
|
||||
} from "./parse.js";
|
||||
export {
|
||||
getQuotaWindows,
|
||||
readClaudeToken,
|
||||
fetchClaudeQuota,
|
||||
toPercent,
|
||||
fetchWithTimeout,
|
||||
claudeConfigDir,
|
||||
} from "./quota.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
|
||||
117
packages/adapters/claude-local/src/server/quota.ts
Normal file
117
packages/adapters/claude-local/src/server/quota.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ProviderQuotaResult, QuotaWindow } from "@paperclipai/adapter-utils";
|
||||
|
||||
export function claudeConfigDir(): string {
|
||||
const fromEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
||||
return path.join(os.homedir(), ".claude");
|
||||
}
|
||||
|
||||
export async function readClaudeToken(): Promise<string | null> {
|
||||
const credPath = path.join(claudeConfigDir(), "credentials.json");
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(credPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const oauth = obj["claudeAiOauth"];
|
||||
if (typeof oauth !== "object" || oauth === null) return null;
|
||||
const token = (oauth as Record<string, unknown>)["accessToken"];
|
||||
return typeof token === "string" && token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
interface AnthropicUsageWindow {
|
||||
utilization?: number | null;
|
||||
resets_at?: string | null;
|
||||
}
|
||||
|
||||
interface AnthropicUsageResponse {
|
||||
five_hour?: AnthropicUsageWindow | null;
|
||||
seven_day?: AnthropicUsageWindow | null;
|
||||
seven_day_sonnet?: AnthropicUsageWindow | null;
|
||||
seven_day_opus?: AnthropicUsageWindow | null;
|
||||
}
|
||||
|
||||
/** Convert a 0-1 utilization fraction to a 0-100 integer percent. Returns null for null/undefined input. */
|
||||
export function toPercent(utilization: number | null | undefined): number | null {
|
||||
if (utilization == null) return null;
|
||||
// utilization is 0-1 fraction; clamp to 100 in case of floating-point overshoot
|
||||
return Math.min(100, Math.round(utilization * 100));
|
||||
}
|
||||
|
||||
/** fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely */
|
||||
export async function fetchWithTimeout(url: string, init: RequestInit, ms = 8000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ms);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClaudeQuota(token: string): Promise<QuotaWindow[]> {
|
||||
const resp = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
});
|
||||
if (!resp.ok) throw new Error(`anthropic usage api returned ${resp.status}`);
|
||||
const body = (await resp.json()) as AnthropicUsageResponse;
|
||||
const windows: QuotaWindow[] = [];
|
||||
|
||||
if (body.five_hour != null) {
|
||||
windows.push({
|
||||
label: "5h",
|
||||
usedPercent: toPercent(body.five_hour.utilization),
|
||||
resetsAt: body.five_hour.resets_at ?? null,
|
||||
valueLabel: null,
|
||||
});
|
||||
}
|
||||
if (body.seven_day != null) {
|
||||
windows.push({
|
||||
label: "7d",
|
||||
usedPercent: toPercent(body.seven_day.utilization),
|
||||
resetsAt: body.seven_day.resets_at ?? null,
|
||||
valueLabel: null,
|
||||
});
|
||||
}
|
||||
if (body.seven_day_sonnet != null) {
|
||||
windows.push({
|
||||
label: "Sonnet 7d",
|
||||
usedPercent: toPercent(body.seven_day_sonnet.utilization),
|
||||
resetsAt: body.seven_day_sonnet.resets_at ?? null,
|
||||
valueLabel: null,
|
||||
});
|
||||
}
|
||||
if (body.seven_day_opus != null) {
|
||||
windows.push({
|
||||
label: "Opus 7d",
|
||||
usedPercent: toPercent(body.seven_day_opus.utilization),
|
||||
resetsAt: body.seven_day_opus.resets_at ?? null,
|
||||
valueLabel: null,
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
|
||||
const token = await readClaudeToken();
|
||||
if (!token) {
|
||||
return { provider: "anthropic", ok: false, error: "no local claude auth token", windows: [] };
|
||||
}
|
||||
const windows = await fetchClaudeQuota(token);
|
||||
return { provider: "anthropic", ok: true, windows };
|
||||
}
|
||||
Reference in New Issue
Block a user