@narumitw/pi-usage 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/package.json +46 -0
- package/src/core.ts +217 -0
- package/src/format.ts +249 -0
- package/src/index.ts +34 -0
- package/src/providers/codex.ts +150 -0
- package/src/providers/openrouter.ts +73 -0
- package/src/query.ts +313 -0
- package/src/types.ts +90 -0
- package/src/usage.ts +676 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export {
|
|
2
|
+
abortError,
|
|
3
|
+
awaitWithDeadline,
|
|
4
|
+
errorMessage,
|
|
5
|
+
fingerprintResolvedAuth,
|
|
6
|
+
redactUsageError,
|
|
7
|
+
runWithConcurrency,
|
|
8
|
+
sanitizeDisplayText,
|
|
9
|
+
UsageCache,
|
|
10
|
+
} from "./core.js";
|
|
11
|
+
export { formatProviderStates, formatUsageReport, formatUsageStatusline } from "./format.js";
|
|
12
|
+
export { normalizeCodexBackendPayload } from "./providers/codex.js";
|
|
13
|
+
export { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
|
|
14
|
+
export {
|
|
15
|
+
adapterForProvider,
|
|
16
|
+
isStaleExtensionContextError,
|
|
17
|
+
providerIsConfigured,
|
|
18
|
+
queryProviderUsage,
|
|
19
|
+
resolveUsageAuth,
|
|
20
|
+
SUPPORTED_ADAPTERS,
|
|
21
|
+
} from "./query.js";
|
|
22
|
+
export type {
|
|
23
|
+
ProviderUsageState,
|
|
24
|
+
ResolvedUsageAuth,
|
|
25
|
+
UsageBucket,
|
|
26
|
+
UsageDisplayState,
|
|
27
|
+
UsageMetric,
|
|
28
|
+
UsageModel,
|
|
29
|
+
UsageProviderAdapter,
|
|
30
|
+
UsageReport,
|
|
31
|
+
UsageSemantics,
|
|
32
|
+
UsageSemanticsKind,
|
|
33
|
+
UsageUnit,
|
|
34
|
+
} from "./types.js";
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { sanitizeDisplayText } from "../core.js";
|
|
2
|
+
import type { CodexBackendPayload, UsageBucket, UsageMetric, UsageReport } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export function normalizeCodexBackendPayload(
|
|
5
|
+
payload: CodexBackendPayload,
|
|
6
|
+
capturedAt: number,
|
|
7
|
+
): UsageReport {
|
|
8
|
+
const buckets: UsageBucket[] = [];
|
|
9
|
+
normalizeRateLimitGroup(buckets, "codex", "Codex", payload.rate_limit, false);
|
|
10
|
+
|
|
11
|
+
const additional = Array.isArray(payload.additional_rate_limits)
|
|
12
|
+
? payload.additional_rate_limits
|
|
13
|
+
: [];
|
|
14
|
+
for (const item of additional) {
|
|
15
|
+
const value = asObject(item);
|
|
16
|
+
const id = asString(value?.metered_feature) ?? asString(value?.limit_name);
|
|
17
|
+
if (!value || !id) continue;
|
|
18
|
+
try {
|
|
19
|
+
normalizeRateLimitGroup(
|
|
20
|
+
buckets,
|
|
21
|
+
id,
|
|
22
|
+
asString(value.limit_name) ?? id,
|
|
23
|
+
value.rate_limit,
|
|
24
|
+
true,
|
|
25
|
+
);
|
|
26
|
+
} catch {
|
|
27
|
+
// Optional provider-specific buckets must not hide otherwise useful primary data.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const metrics: UsageMetric[] = [];
|
|
32
|
+
const credits = asObject(payload.credits);
|
|
33
|
+
if (credits?.has_credits === true) {
|
|
34
|
+
if (credits.unlimited === true) {
|
|
35
|
+
metrics.push({ id: "credits", label: "Credits", value: "unlimited" });
|
|
36
|
+
} else {
|
|
37
|
+
const balance = asNumber(credits.balance);
|
|
38
|
+
if (balance !== undefined) {
|
|
39
|
+
metrics.push({ id: "credits", label: "Credits", value: balance, unit: "count" });
|
|
40
|
+
} else {
|
|
41
|
+
metrics.push({ id: "credits", label: "Credits", value: "available" });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} else if (credits?.has_credits === false) {
|
|
45
|
+
metrics.push({ id: "credits", label: "Credits", value: "none" });
|
|
46
|
+
}
|
|
47
|
+
const resetCredits = asObject(payload.rate_limit_reset_credits);
|
|
48
|
+
const resetCount = asNonnegativeInteger(resetCredits?.available_count);
|
|
49
|
+
if (resetCount !== undefined) {
|
|
50
|
+
metrics.push({
|
|
51
|
+
id: "reset-credits",
|
|
52
|
+
label: "Usage limit resets",
|
|
53
|
+
value: resetCount,
|
|
54
|
+
unit: "count",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (buckets.length === 0 && metrics.length === 0) {
|
|
58
|
+
throw new Error("Codex usage endpoint returned no displayable usage data.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const planType = asString(payload.plan_type);
|
|
62
|
+
return {
|
|
63
|
+
providerId: "openai-codex",
|
|
64
|
+
providerName: "OpenAI Codex",
|
|
65
|
+
capturedAt,
|
|
66
|
+
source: "codex-pi-auth",
|
|
67
|
+
semantics: {
|
|
68
|
+
kind: "consumer-subscription",
|
|
69
|
+
label: "ChatGPT subscription limits",
|
|
70
|
+
},
|
|
71
|
+
buckets,
|
|
72
|
+
metrics,
|
|
73
|
+
...(planType ? { notes: [`Plan: ${planType}`] } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeRateLimitGroup(
|
|
78
|
+
buckets: UsageBucket[],
|
|
79
|
+
groupId: string,
|
|
80
|
+
groupLabel: string,
|
|
81
|
+
raw: unknown,
|
|
82
|
+
optional: boolean,
|
|
83
|
+
): void {
|
|
84
|
+
if (raw === undefined || raw === null) return;
|
|
85
|
+
const details = asObject(raw);
|
|
86
|
+
if (!details) {
|
|
87
|
+
if (optional) return;
|
|
88
|
+
throw new Error("Codex rate limit was not an object.");
|
|
89
|
+
}
|
|
90
|
+
addWindow(buckets, groupId, groupLabel, "primary", details.primary_window);
|
|
91
|
+
addWindow(buckets, groupId, groupLabel, "secondary", details.secondary_window);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function addWindow(
|
|
95
|
+
buckets: UsageBucket[],
|
|
96
|
+
groupId: string,
|
|
97
|
+
groupLabel: string,
|
|
98
|
+
position: "primary" | "secondary",
|
|
99
|
+
raw: unknown,
|
|
100
|
+
): void {
|
|
101
|
+
if (raw === undefined || raw === null) return;
|
|
102
|
+
const value = asObject(raw);
|
|
103
|
+
if (!value) throw new Error("Codex rate-limit window was not an object.");
|
|
104
|
+
const used = asNumber(value.used_percent);
|
|
105
|
+
if (used === undefined) return;
|
|
106
|
+
const seconds = asNumber(value.limit_window_seconds);
|
|
107
|
+
const resetsAt = asNumber(value.reset_at);
|
|
108
|
+
buckets.push({
|
|
109
|
+
id: `${groupId}:${position}`,
|
|
110
|
+
label: position === "primary" ? "Primary limit" : "Secondary limit",
|
|
111
|
+
groupId,
|
|
112
|
+
groupLabel,
|
|
113
|
+
modelKeys: [groupId, groupLabel],
|
|
114
|
+
used,
|
|
115
|
+
remaining: 100 - clampPercent(used),
|
|
116
|
+
limit: 100,
|
|
117
|
+
unit: "percent",
|
|
118
|
+
...(seconds !== undefined && seconds > 0 ? { windowMinutes: Math.ceil(seconds / 60) } : {}),
|
|
119
|
+
...(resetsAt !== undefined ? { resetsAt } : {}),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
124
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
125
|
+
return value as Record<string, unknown>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function asString(value: unknown): string | undefined {
|
|
129
|
+
if (typeof value !== "string") return undefined;
|
|
130
|
+
return sanitizeDisplayText(value, 160) || undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function asNumber(value: unknown): number | undefined {
|
|
134
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
135
|
+
if (typeof value === "string" && value.trim()) {
|
|
136
|
+
const parsed = Number(value);
|
|
137
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function asNonnegativeInteger(value: unknown): number | undefined {
|
|
143
|
+
const parsed = asNumber(value);
|
|
144
|
+
if (parsed === undefined || !Number.isSafeInteger(parsed)) return undefined;
|
|
145
|
+
return Math.max(0, parsed);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function clampPercent(value: number): number {
|
|
149
|
+
return Math.min(100, Math.max(0, value));
|
|
150
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { sanitizeDisplayText } from "../core.js";
|
|
2
|
+
import type { OpenRouterKeyPayload, UsageBucket, UsageMetric, UsageReport } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export function normalizeOpenRouterKeyPayload(
|
|
5
|
+
payload: OpenRouterKeyPayload,
|
|
6
|
+
capturedAt: number,
|
|
7
|
+
): UsageReport {
|
|
8
|
+
const data = asObject(payload.data);
|
|
9
|
+
if (!data) throw new Error("OpenRouter key response data was not an object.");
|
|
10
|
+
|
|
11
|
+
const limit = asNonnegativeNumber(data.limit);
|
|
12
|
+
const remaining = asNonnegativeNumber(data.limit_remaining);
|
|
13
|
+
const period = asString(data.limit_reset);
|
|
14
|
+
const totalUsage = asNonnegativeNumber(data.usage);
|
|
15
|
+
const buckets: UsageBucket[] = [];
|
|
16
|
+
if (limit !== undefined) {
|
|
17
|
+
buckets.push({
|
|
18
|
+
id: "key-limit",
|
|
19
|
+
label: "Key limit",
|
|
20
|
+
...(remaining !== undefined ? { used: Math.max(0, limit - remaining), remaining } : {}),
|
|
21
|
+
limit,
|
|
22
|
+
unit: "usd",
|
|
23
|
+
...(period ? { period } : {}),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const metrics: UsageMetric[] = [];
|
|
28
|
+
addUsageMetric(metrics, "usage-daily", "Usage today", data.usage_daily);
|
|
29
|
+
addUsageMetric(metrics, "usage-weekly", "Usage this week", data.usage_weekly);
|
|
30
|
+
addUsageMetric(metrics, "usage-monthly", "Usage this month", data.usage_monthly);
|
|
31
|
+
addUsageMetric(metrics, "usage-total", "All-time usage", totalUsage);
|
|
32
|
+
if (buckets.length === 0 && metrics.length === 0) {
|
|
33
|
+
throw new Error("OpenRouter key response returned no displayable usage data.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const notes: string[] = [];
|
|
37
|
+
if (data.limit === null) notes.push("No per-key spend cap");
|
|
38
|
+
else if (limit === undefined) notes.push("Per-key spend cap unavailable");
|
|
39
|
+
if (data.is_free_tier === true) notes.push("Free-tier API key");
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
providerId: "openrouter",
|
|
43
|
+
providerName: "OpenRouter",
|
|
44
|
+
capturedAt,
|
|
45
|
+
source: "openrouter-key",
|
|
46
|
+
semantics: { kind: "api-key", label: "API-key spend limits" },
|
|
47
|
+
accountLabel: asString(data.label),
|
|
48
|
+
buckets,
|
|
49
|
+
metrics,
|
|
50
|
+
...(notes.length > 0 ? { notes } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function addUsageMetric(metrics: UsageMetric[], id: string, label: string, value: unknown): void {
|
|
55
|
+
const amount = typeof value === "number" ? asNonnegativeNumber(value) : undefined;
|
|
56
|
+
if (amount === undefined) return;
|
|
57
|
+
metrics.push({ id, label, value: amount, unit: "usd" });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
61
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
62
|
+
return value as Record<string, unknown>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function asString(value: unknown): string | undefined {
|
|
66
|
+
if (typeof value !== "string") return undefined;
|
|
67
|
+
return sanitizeDisplayText(value, 80) || undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function asNonnegativeNumber(value: unknown): number | undefined {
|
|
71
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
|
|
72
|
+
return value;
|
|
73
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { errorMessage, fingerprintResolvedAuth, redactUsageError } from "./core.js";
|
|
4
|
+
import { normalizeCodexBackendPayload } from "./providers/codex.js";
|
|
5
|
+
import { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
|
|
6
|
+
import type {
|
|
7
|
+
CodexBackendPayload,
|
|
8
|
+
OpenRouterKeyPayload,
|
|
9
|
+
PiModel,
|
|
10
|
+
ResolvedUsageAuth,
|
|
11
|
+
UsageProviderAdapter,
|
|
12
|
+
UsageReport,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
16
|
+
const OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key";
|
|
17
|
+
const MAX_SUCCESS_BODY_BYTES = 64 * 1024;
|
|
18
|
+
const MAX_ERROR_BODY_BYTES = 4 * 1024;
|
|
19
|
+
|
|
20
|
+
export const AUTH_FINGERPRINT_SALT = randomBytes(32);
|
|
21
|
+
|
|
22
|
+
export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
|
|
23
|
+
{
|
|
24
|
+
id: "openai-codex",
|
|
25
|
+
displayName: "OpenAI Codex",
|
|
26
|
+
semantics: {
|
|
27
|
+
kind: "consumer-subscription",
|
|
28
|
+
label: "ChatGPT subscription limits",
|
|
29
|
+
},
|
|
30
|
+
async query(auth, signal, timeoutMs) {
|
|
31
|
+
const payload = await fetchProviderJson(
|
|
32
|
+
CODEX_USAGE_URL,
|
|
33
|
+
auth,
|
|
34
|
+
signal,
|
|
35
|
+
timeoutMs,
|
|
36
|
+
"Codex usage endpoint",
|
|
37
|
+
);
|
|
38
|
+
return normalizeCodexBackendPayload(payload as CodexBackendPayload, Date.now());
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "openrouter",
|
|
43
|
+
displayName: "OpenRouter",
|
|
44
|
+
semantics: { kind: "api-key", label: "API-key spend limits" },
|
|
45
|
+
async query(auth, signal, timeoutMs) {
|
|
46
|
+
const payload = await fetchProviderJson(
|
|
47
|
+
OPENROUTER_KEY_URL,
|
|
48
|
+
auth,
|
|
49
|
+
signal,
|
|
50
|
+
timeoutMs,
|
|
51
|
+
"OpenRouter key endpoint",
|
|
52
|
+
);
|
|
53
|
+
return normalizeOpenRouterKeyPayload(payload as OpenRouterKeyPayload, Date.now());
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export function adapterForProvider(
|
|
59
|
+
providerId: string | undefined,
|
|
60
|
+
): UsageProviderAdapter | undefined {
|
|
61
|
+
return SUPPORTED_ADAPTERS.find((adapter) => adapter.id === providerId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isStaleExtensionContextError(error: unknown): boolean {
|
|
65
|
+
return (
|
|
66
|
+
error instanceof Error &&
|
|
67
|
+
error.message.includes("This extension ctx is stale after session replacement or reload")
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function resolveUsageAuth(
|
|
72
|
+
ctx: ExtensionContext,
|
|
73
|
+
adapter: UsageProviderAdapter,
|
|
74
|
+
salt: Uint8Array = AUTH_FINGERPRINT_SALT,
|
|
75
|
+
): Promise<ResolvedUsageAuth | undefined> {
|
|
76
|
+
if (ctx.model?.provider === adapter.id && !hasOfficialOrigin(ctx.model, adapter.id)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`${adapter.displayName} usage cannot send a custom provider base URL credential to the official usage endpoint.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const model = candidateModels(ctx, adapter.id).find((candidate) =>
|
|
83
|
+
hasOfficialOrigin(candidate, adapter.id),
|
|
84
|
+
);
|
|
85
|
+
if (!model) return undefined;
|
|
86
|
+
const registry = ctx.modelRegistry as unknown as UsageAuthRegistry;
|
|
87
|
+
let modelAuth: RequestAuth | undefined;
|
|
88
|
+
if (ctx.model?.provider === adapter.id && typeof registry.getApiKeyAndHeaders === "function") {
|
|
89
|
+
const result = await registry.getApiKeyAndHeaders(ctx.model);
|
|
90
|
+
if (!result.ok) throw new Error(redactUsageError(result.error));
|
|
91
|
+
if (authorizationFrom(result)) modelAuth = result;
|
|
92
|
+
}
|
|
93
|
+
if (typeof registry.getProviderAuth !== "function") {
|
|
94
|
+
throw new Error("pi-usage requires Pi 0.81.0 or newer to validate resolved provider auth.");
|
|
95
|
+
}
|
|
96
|
+
const providerResult = await registry.getProviderAuth(adapter.id);
|
|
97
|
+
if (
|
|
98
|
+
providerResult?.auth.baseUrl &&
|
|
99
|
+
!hasOfficialUrlOrigin(providerResult.auth.baseUrl, adapter.id)
|
|
100
|
+
) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`${adapter.displayName} usage cannot send a proxy-resolved credential to the official usage endpoint.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const auth = modelAuth ?? providerResult?.auth;
|
|
106
|
+
if (!auth) return undefined;
|
|
107
|
+
const authorization = authorizationFrom(auth);
|
|
108
|
+
if (!authorization) return undefined;
|
|
109
|
+
const headers = { Authorization: authorization };
|
|
110
|
+
const secrets = [auth.apiKey, headerValue(auth.headers, "Authorization"), authorization].filter(
|
|
111
|
+
(value): value is string => Boolean(value),
|
|
112
|
+
);
|
|
113
|
+
return {
|
|
114
|
+
apiKey: auth.apiKey,
|
|
115
|
+
headers,
|
|
116
|
+
fingerprint: fingerprintResolvedAuth({ headers }, salt),
|
|
117
|
+
secrets,
|
|
118
|
+
model,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function queryProviderUsage(
|
|
123
|
+
adapter: UsageProviderAdapter,
|
|
124
|
+
auth: ResolvedUsageAuth,
|
|
125
|
+
signal: AbortSignal,
|
|
126
|
+
timeoutMs: number,
|
|
127
|
+
): Promise<UsageReport> {
|
|
128
|
+
try {
|
|
129
|
+
return await adapter.query(auth, signal, timeoutMs);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
|
|
132
|
+
throw new Error(redactUsageError(errorMessage(error), auth.secrets));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function providerIsConfigured(ctx: ExtensionContext, providerId: string): boolean {
|
|
137
|
+
try {
|
|
138
|
+
return ctx.modelRegistry.getProviderAuthStatus(providerId).configured;
|
|
139
|
+
} catch {
|
|
140
|
+
return candidateModels(ctx, providerId).length > 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function candidateModels(ctx: ExtensionContext, providerId: string): PiModel[] {
|
|
145
|
+
const candidates: PiModel[] = [];
|
|
146
|
+
const seen = new Set<string>();
|
|
147
|
+
const add = (model: PiModel | undefined) => {
|
|
148
|
+
if (!model || model.provider !== providerId) return;
|
|
149
|
+
const key = `${model.provider}/${model.id}`;
|
|
150
|
+
if (seen.has(key)) return;
|
|
151
|
+
seen.add(key);
|
|
152
|
+
candidates.push(model);
|
|
153
|
+
};
|
|
154
|
+
add(ctx.model);
|
|
155
|
+
for (const model of ctx.modelRegistry.getAvailable()) add(model);
|
|
156
|
+
for (const model of ctx.modelRegistry.getAll()) add(model);
|
|
157
|
+
return candidates;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function fetchProviderJson(
|
|
161
|
+
url: string,
|
|
162
|
+
auth: ResolvedUsageAuth,
|
|
163
|
+
signal: AbortSignal,
|
|
164
|
+
timeoutMs: number,
|
|
165
|
+
description: string,
|
|
166
|
+
): Promise<Record<string, unknown>> {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
let timedOut = false;
|
|
169
|
+
const abortFromCaller = () => controller.abort();
|
|
170
|
+
if (signal.aborted) controller.abort();
|
|
171
|
+
else signal.addEventListener("abort", abortFromCaller, { once: true });
|
|
172
|
+
const timeout = setTimeout(() => {
|
|
173
|
+
timedOut = true;
|
|
174
|
+
controller.abort();
|
|
175
|
+
}, timeoutMs);
|
|
176
|
+
try {
|
|
177
|
+
const headers = { ...auth.headers };
|
|
178
|
+
if (!hasHeader(headers, "User-Agent")) headers["User-Agent"] = "pi-usage";
|
|
179
|
+
const response = await fetch(url, { headers, signal: controller.signal });
|
|
180
|
+
if (controller.signal.aborted)
|
|
181
|
+
throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
|
|
182
|
+
const text = await readBoundedResponse(
|
|
183
|
+
response,
|
|
184
|
+
response.ok ? MAX_SUCCESS_BODY_BYTES : MAX_ERROR_BODY_BYTES,
|
|
185
|
+
!response.ok,
|
|
186
|
+
description,
|
|
187
|
+
);
|
|
188
|
+
if (controller.signal.aborted)
|
|
189
|
+
throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`${description} returned ${response.status} ${response.statusText}: ${redactUsageError(text, auth.secrets)}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
let parsed: unknown;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(text) as unknown;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
throw new Error(`${description} returned invalid JSON: ${errorMessage(error)}`);
|
|
200
|
+
}
|
|
201
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
202
|
+
throw new Error(`${description} response was not an object.`);
|
|
203
|
+
}
|
|
204
|
+
return parsed as Record<string, unknown>;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (timedOut) {
|
|
207
|
+
throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s while fetching usage.`);
|
|
208
|
+
}
|
|
209
|
+
if (signal.aborted)
|
|
210
|
+
throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
|
|
211
|
+
throw error;
|
|
212
|
+
} finally {
|
|
213
|
+
clearTimeout(timeout);
|
|
214
|
+
signal.removeEventListener("abort", abortFromCaller);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function readBoundedResponse(
|
|
219
|
+
response: Response,
|
|
220
|
+
maxBytes: number,
|
|
221
|
+
truncateOverflow: boolean,
|
|
222
|
+
description: string,
|
|
223
|
+
): Promise<string> {
|
|
224
|
+
if (!response.body) return "";
|
|
225
|
+
const reader = response.body.getReader();
|
|
226
|
+
const chunks: Uint8Array[] = [];
|
|
227
|
+
let total = 0;
|
|
228
|
+
let truncated = false;
|
|
229
|
+
try {
|
|
230
|
+
while (true) {
|
|
231
|
+
const { done, value } = await reader.read();
|
|
232
|
+
if (done) break;
|
|
233
|
+
const remaining = maxBytes - total;
|
|
234
|
+
if (value.byteLength > remaining) {
|
|
235
|
+
if (remaining > 0) chunks.push(value.subarray(0, remaining));
|
|
236
|
+
total = maxBytes;
|
|
237
|
+
truncated = true;
|
|
238
|
+
await reader.cancel();
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
chunks.push(value);
|
|
242
|
+
total += value.byteLength;
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
reader.releaseLock();
|
|
246
|
+
}
|
|
247
|
+
if (truncated && !truncateOverflow) {
|
|
248
|
+
throw new Error(`${description} response exceeded ${maxBytes} bytes.`);
|
|
249
|
+
}
|
|
250
|
+
const body = new Uint8Array(total);
|
|
251
|
+
let offset = 0;
|
|
252
|
+
for (const chunk of chunks) {
|
|
253
|
+
body.set(chunk, offset);
|
|
254
|
+
offset += chunk.byteLength;
|
|
255
|
+
}
|
|
256
|
+
const text = new TextDecoder().decode(body);
|
|
257
|
+
return truncated ? `${text}…` : text;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
type RequestAuth = {
|
|
261
|
+
apiKey?: string;
|
|
262
|
+
headers?: Record<string, string | null>;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
type UsageAuthRegistry = {
|
|
266
|
+
getApiKeyAndHeaders?(
|
|
267
|
+
model: PiModel,
|
|
268
|
+
): Promise<({ ok: true } & RequestAuth) | { ok: false; error: string }>;
|
|
269
|
+
getProviderAuth?(providerId: string): Promise<
|
|
270
|
+
| {
|
|
271
|
+
auth: RequestAuth & { baseUrl?: string };
|
|
272
|
+
}
|
|
273
|
+
| undefined
|
|
274
|
+
>;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
function authorizationFrom(auth: RequestAuth): string | undefined {
|
|
278
|
+
return (
|
|
279
|
+
headerValue(auth.headers, "Authorization") ??
|
|
280
|
+
(auth.apiKey ? `Bearer ${auth.apiKey}` : undefined)
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function hasOfficialOrigin(model: PiModel, providerId: string): boolean {
|
|
285
|
+
return hasOfficialUrlOrigin(model.baseUrl, providerId);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function hasOfficialUrlOrigin(value: string, providerId: string): boolean {
|
|
289
|
+
const expected = providerId === "openai-codex" ? "https://chatgpt.com" : "https://openrouter.ai";
|
|
290
|
+
try {
|
|
291
|
+
return new URL(value).origin === expected;
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function headerValue(
|
|
298
|
+
headers: Record<string, string | null> | undefined,
|
|
299
|
+
name: string,
|
|
300
|
+
): string | undefined {
|
|
301
|
+
const entry = Object.entries(headers ?? {}).find(
|
|
302
|
+
([candidate]) => candidate.toLowerCase() === name.toLowerCase(),
|
|
303
|
+
);
|
|
304
|
+
return entry?.[1] ?? undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
308
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isAbortError(error: unknown): boolean {
|
|
312
|
+
return error instanceof Error && error.name === "AbortError";
|
|
313
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type PiModel = NonNullable<ExtensionContext["model"]>;
|
|
4
|
+
export type UsageModel = Pick<PiModel, "id" | "name" | "provider">;
|
|
5
|
+
|
|
6
|
+
export type UsageSemanticsKind = "consumer-subscription" | "api-key" | "project";
|
|
7
|
+
export type UsageUnit = "percent" | "usd" | "count";
|
|
8
|
+
export type UsageDisplayState = "current" | "configured";
|
|
9
|
+
|
|
10
|
+
export interface UsageSemantics {
|
|
11
|
+
kind: UsageSemanticsKind;
|
|
12
|
+
label: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UsageBucket {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
groupId?: string;
|
|
19
|
+
groupLabel?: string;
|
|
20
|
+
modelKeys?: string[];
|
|
21
|
+
used?: number;
|
|
22
|
+
remaining?: number;
|
|
23
|
+
limit?: number;
|
|
24
|
+
unit: UsageUnit;
|
|
25
|
+
period?: string;
|
|
26
|
+
windowMinutes?: number;
|
|
27
|
+
resetsAt?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UsageMetric {
|
|
31
|
+
id: string;
|
|
32
|
+
label: string;
|
|
33
|
+
value: number | string;
|
|
34
|
+
unit?: UsageUnit;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UsageReport {
|
|
38
|
+
providerId: string;
|
|
39
|
+
providerName: string;
|
|
40
|
+
capturedAt: number;
|
|
41
|
+
source: string;
|
|
42
|
+
semantics: UsageSemantics;
|
|
43
|
+
accountLabel?: string;
|
|
44
|
+
buckets: UsageBucket[];
|
|
45
|
+
metrics: UsageMetric[];
|
|
46
|
+
notes?: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ResolvedUsageAuth {
|
|
50
|
+
apiKey?: string;
|
|
51
|
+
headers: Record<string, string>;
|
|
52
|
+
fingerprint: string;
|
|
53
|
+
secrets: string[];
|
|
54
|
+
model: PiModel;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UsageProviderAdapter {
|
|
58
|
+
id: string;
|
|
59
|
+
displayName: string;
|
|
60
|
+
semantics: UsageSemantics;
|
|
61
|
+
query(auth: ResolvedUsageAuth, signal: AbortSignal, timeoutMs: number): Promise<UsageReport>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type ProviderUsageState =
|
|
65
|
+
| {
|
|
66
|
+
providerId: string;
|
|
67
|
+
providerName: string;
|
|
68
|
+
displayState: UsageDisplayState;
|
|
69
|
+
status: "ready";
|
|
70
|
+
report: UsageReport;
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
providerId: string;
|
|
74
|
+
providerName: string;
|
|
75
|
+
displayState: UsageDisplayState;
|
|
76
|
+
status: "unsupported" | "auth-unavailable" | "query-failed";
|
|
77
|
+
message: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type OpenRouterKeyPayload = {
|
|
81
|
+
data?: unknown;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type CodexBackendPayload = {
|
|
85
|
+
plan_type?: unknown;
|
|
86
|
+
rate_limit?: unknown;
|
|
87
|
+
additional_rate_limits?: unknown;
|
|
88
|
+
credits?: unknown;
|
|
89
|
+
rate_limit_reset_credits?: unknown;
|
|
90
|
+
};
|