pi-myusage 0.1.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/src/auth.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { getAgentDir, readStoredCredential, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { Credentials, ProviderTarget } from "./types.ts";
5
+
6
+ function bearerOf(headers: Record<string, string> | undefined, apiKey: string | undefined): string | undefined {
7
+ const auth = headers?.Authorization ?? headers?.authorization;
8
+ if (auth) return auth.startsWith("Bearer ") ? auth.slice(7) : auth;
9
+ return apiKey;
10
+ }
11
+
12
+ function normalizeHeaders(headers: unknown): Record<string, string> | undefined {
13
+ if (!headers || typeof headers !== "object") return undefined;
14
+ const out: Record<string, string> = {};
15
+ for (const [key, value] of Object.entries(headers)) {
16
+ if (typeof value === "string") out[key] = value;
17
+ }
18
+ return Object.keys(out).length ? out : undefined;
19
+ }
20
+
21
+ async function readAuthJson(): Promise<Record<string, Record<string, unknown>>> {
22
+ try {
23
+ return JSON.parse(await readFile(join(getAgentDir(), "auth.json"), "utf8")) as never;
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ const STATIC_CREDENTIAL_TTL_MS = 30_000;
30
+ const credentialMemo = new Map<string, { value: Credentials | undefined; at: number }>();
31
+
32
+ export async function resolveCredentials(
33
+ ctx: ExtensionContext,
34
+ target: ProviderTarget,
35
+ ): Promise<Credentials | undefined> {
36
+ const memo = credentialMemo.get(target.providerId);
37
+ if (memo && Date.now() - memo.at <= STATIC_CREDENTIAL_TTL_MS) return memo.value;
38
+ const resolved = await resolveCredentialsUncached(ctx, target);
39
+ const oauth = Boolean(target.model && ctx.modelRegistry.isUsingOAuth(target.model));
40
+ if (resolved === undefined || (resolved.source === "model" && !oauth)) {
41
+ credentialMemo.set(target.providerId, { value: resolved, at: Date.now() });
42
+ }
43
+ return resolved;
44
+ }
45
+
46
+ async function attempt<T>(step: () => Promise<T>): Promise<T | undefined> {
47
+ try {
48
+ return await step();
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ async function resolveCredentialsUncached(
55
+ ctx: ExtensionContext,
56
+ target: ProviderTarget,
57
+ ): Promise<Credentials | undefined> {
58
+ const model = target.model;
59
+ if (model) {
60
+ const resolved = await attempt(() => ctx.modelRegistry.getApiKeyAndHeaders(model));
61
+ if (resolved?.ok) {
62
+ const headers = normalizeHeaders(resolved.headers);
63
+ const apiKey = bearerOf(headers, resolved.apiKey);
64
+ if (apiKey || headers) {
65
+ return { apiKey, headers, baseUrl: resolved.baseUrl ?? model.baseUrl, source: "model" };
66
+ }
67
+ }
68
+ }
69
+
70
+ const providerAuth = await attempt(() => ctx.modelRegistry.getProviderAuth(target.providerId));
71
+ if (providerAuth) {
72
+ const auth = providerAuth.auth;
73
+ const headers = normalizeHeaders(auth?.headers);
74
+ const apiKey = auth ? bearerOf(headers, auth.apiKey) : undefined;
75
+ if (apiKey || headers) {
76
+ return { apiKey, headers, baseUrl: auth?.baseUrl ?? target.baseUrl, source: "provider" };
77
+ }
78
+ }
79
+
80
+ const stored = readStoredCredential(target.providerId);
81
+ if (stored && typeof stored === "object") {
82
+ const record = stored as Record<string, unknown>;
83
+ const headers = normalizeHeaders(record.headers);
84
+ const apiKey = bearerOf(headers, record.apiKey as string | undefined);
85
+ if (apiKey) return { apiKey, headers, source: "stored" };
86
+ }
87
+
88
+ const raw = (await readAuthJson())[target.providerId];
89
+ if (raw) {
90
+ const headers = normalizeHeaders(raw.headers);
91
+ const apiKey = bearerOf(headers, raw.apiKey as string | undefined);
92
+ if (apiKey) return { apiKey, headers, source: "stored" };
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ export async function codexAccountId(_ctx: ExtensionContext, creds: Credentials | undefined): Promise<string | undefined> {
98
+ if (creds?.headers?.["chatgpt-account-id"] ?? creds?.headers?.["ChatGPT-Account-Id"]) {
99
+ return (creds.headers["chatgpt-account-id"] ?? creds.headers["ChatGPT-Account-Id"]) as string;
100
+ }
101
+ const raw = (await readAuthJson())["openai-codex"];
102
+ const id = raw?.accountId ?? raw?.chatgpt_account_id ?? raw?.account_id;
103
+ return typeof id === "string" ? id : undefined;
104
+ }
@@ -0,0 +1,94 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+
3
+ export type FastMode = false | "fast" | "ultrafast";
4
+
5
+ export const FAST_TIER = "priority";
6
+ export const ULTRAFAST_TIER = "ultrafast";
7
+ const STANDARD_TIER = "default";
8
+
9
+ export function supportsCodexFastModelId(modelId: string): boolean {
10
+ return modelId.toLowerCase().startsWith("gpt-");
11
+ }
12
+
13
+ export function codexFastCostMultiplier(modelId: string): number {
14
+ return modelId === "gpt-5.5" ? 2.5 : 2;
15
+ }
16
+
17
+ export function tierForMode(mode: FastMode): string {
18
+ if (mode === "ultrafast") return ULTRAFAST_TIER;
19
+ if (mode === "fast") return FAST_TIER;
20
+ return STANDARD_TIER;
21
+ }
22
+
23
+ function isOpenAiApi(model: Model<Api> | undefined): boolean {
24
+ return typeof model?.api === "string" && model.api.startsWith("openai");
25
+ }
26
+
27
+ function isOfficialCodexModel(model: Model<Api> | undefined): boolean {
28
+ if (!model || model.provider !== "openai-codex") return false;
29
+ try {
30
+ return new URL(model.baseUrl).origin === "https://chatgpt.com";
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export type CodexFastAvailability =
37
+ | { kind: "available"; mode: FastMode }
38
+ | { kind: "unavailable"; reason: string };
39
+
40
+ /** Fast and Ultrafast apply to any gpt-* model on an OpenAI-family API, regardless of provider or gateway. */
41
+ export function codexFastAvailability(model: Model<Api> | undefined, mode: FastMode): CodexFastAvailability {
42
+ if (!model) return { kind: "unavailable", reason: "No active model." };
43
+ if (!supportsCodexFastModelId(model.id)) {
44
+ return { kind: "unavailable", reason: `${model.id} does not support Fast or Ultrafast.` };
45
+ }
46
+ if (!isOpenAiApi(model)) {
47
+ return { kind: "unavailable", reason: "Fast and Ultrafast require an OpenAI-family API." };
48
+ }
49
+ return { kind: "available", mode };
50
+ }
51
+
52
+ export function rewriteServiceTierPayload(
53
+ payload: unknown,
54
+ model: Model<Api> | undefined,
55
+ mode: FastMode,
56
+ ): unknown | undefined {
57
+ if (!model || !supportsCodexFastModelId(model.id) || !isOpenAiApi(model) || !isRecord(payload)) {
58
+ return undefined;
59
+ }
60
+ return { ...payload, service_tier: tierForMode(mode) };
61
+ }
62
+
63
+ export function fastSuffix(mode: FastMode): string | undefined {
64
+ return mode === false ? undefined : mode;
65
+ }
66
+
67
+ export function fastModeIsEffective(model: Model<Api> | undefined, mode: FastMode): boolean {
68
+ return mode !== false && codexFastAvailability(model, mode).kind === "available";
69
+ }
70
+
71
+ /** Cost correction (×2/×2.5 priority pricing) applies only to Fast on the official Codex endpoint. */
72
+ export function correctCodexFastMessage(
73
+ message: unknown,
74
+ model: Model<Api> | undefined,
75
+ tier: string,
76
+ ): { message: unknown } | undefined {
77
+ if (tier !== FAST_TIER || !isOfficialCodexModel(model) || !isRecord(message) || message.role !== "assistant") {
78
+ return undefined;
79
+ }
80
+ const usage = isRecord(message.usage) ? message.usage : undefined;
81
+ const cost = usage && isRecord(usage.cost) ? usage.cost : undefined;
82
+ if (!usage || !cost) return undefined;
83
+ const multiplier = codexFastCostMultiplier(model?.id ?? "");
84
+ const corrected = structuredClone(usage) as Record<string, unknown>;
85
+ const correctedCost = corrected.cost as Record<string, number>;
86
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
87
+ if (typeof correctedCost[key] === "number") correctedCost[key] *= multiplier;
88
+ }
89
+ return { message: { ...message, usage: corrected } };
90
+ }
91
+
92
+ function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === "object" && value !== null && !Array.isArray(value);
94
+ }
package/src/format.ts ADDED
@@ -0,0 +1,94 @@
1
+ import type { Metric, UsageSnapshot } from "./types.ts";
2
+
3
+ export type QuotaWindowMetric = Extract<Metric, { kind: "quota-window" }>;
4
+
5
+ export function windowsSummary(windows: readonly QuotaWindowMetric[]): string | undefined {
6
+ if (!windows.length) return undefined;
7
+ const firstWord = (windows[0].label.split(" ")[0] ?? "").trim();
8
+ const sharedPrefix =
9
+ windows.length > 1 && firstWord && windows.every((w) => w.label.startsWith(`${firstWord} `))
10
+ ? `${firstWord} `
11
+ : "";
12
+ const parts = windows.map((w, index) => {
13
+ const label = sharedPrefix && index > 0 ? w.label.slice(sharedPrefix.length) : w.label;
14
+ const reset = shortReset(w.resetAt);
15
+ return `${label} ${Math.round(w.remainingFraction * 100)}%${reset ? ` (${reset})` : ""}`;
16
+ });
17
+ return parts.join(" · ");
18
+ }
19
+
20
+ export function shortReset(resetAt: string | undefined): string | undefined {
21
+ const text = relativeTime(resetAt);
22
+ if (!text) return undefined;
23
+ if (text === "reset due") return "due";
24
+ return text.replace(/^resets in /, "");
25
+ }
26
+
27
+ export function percentBar(fraction: number, width = 10): string {
28
+ const clamped = Math.min(1, Math.max(0, fraction));
29
+ const count = Math.round(clamped * width);
30
+ return `${"━".repeat(count)}${"─".repeat(width - count)}`;
31
+ }
32
+
33
+ export function relativeTime(value?: string): string | undefined {
34
+ if (!value) return undefined;
35
+ const delta = new Date(value).getTime() - Date.now();
36
+ if (!Number.isFinite(delta)) return undefined;
37
+ if (delta <= 0) return "reset due";
38
+ const minutes = Math.ceil(delta / 60_000);
39
+ if (minutes < 60) return `resets in ${minutes}m`;
40
+ const hours = Math.floor(minutes / 60);
41
+ const mins = minutes % 60;
42
+ if (hours < 48) return `resets in ${hours}h${mins ? ` ${mins}m` : ""}`;
43
+ return `resets in ${Math.floor(hours / 24)}d ${hours % 24}h`;
44
+ }
45
+
46
+ export function metricText(metric: Metric): string {
47
+ switch (metric.kind) {
48
+ case "balance": {
49
+ const symbol =
50
+ metric.currency === "CNY" || metric.currency === "RMB"
51
+ ? "¥"
52
+ : metric.currency === "USD"
53
+ ? "$"
54
+ : `${metric.currency} `;
55
+ return `${metric.label}: ${symbol}${metric.amount.toFixed(2)}${metric.detail ? ` · ${metric.detail}` : ""}`;
56
+ }
57
+ case "quota-window": {
58
+ const reset = relativeTime(metric.resetAt);
59
+ return `${metric.label} ${percentBar(metric.remainingFraction)} ${Math.round(metric.remainingFraction * 100)}% left${reset ? ` · ${reset}` : ""}`;
60
+ }
61
+ case "usage-limit":
62
+ return `${metric.label}: ${metric.used}/${metric.limit} ${metric.unit}`;
63
+ case "status":
64
+ return `${metric.label}: ${metric.value}`;
65
+ }
66
+ }
67
+
68
+ export function compactStatus(snapshot: UsageSnapshot | undefined): string | undefined {
69
+ if (!snapshot) return undefined;
70
+ if (snapshot.summary) return snapshot.summary;
71
+ switch (snapshot.state) {
72
+ case "ok":
73
+ case "empty":
74
+ return undefined;
75
+ case "unauthorized":
76
+ return `${snapshot.displayName}: auth needed`;
77
+ case "unsupported":
78
+ return `${snapshot.displayName}: unsupported`;
79
+ default:
80
+ return `${snapshot.displayName}: unavailable`;
81
+ }
82
+ }
83
+
84
+ export function detailLines(snapshot: UsageSnapshot): string[] {
85
+ const lines = [`${snapshot.displayName} [${snapshot.state}]`];
86
+ if (snapshot.error) lines.push(` Error: ${snapshot.error}`);
87
+ if (!snapshot.accounts.length) lines.push(" No data");
88
+ for (const account of snapshot.accounts) {
89
+ lines.push(` ${account.label}`);
90
+ if (!account.metrics.length) lines.push(" No metrics reported");
91
+ for (const metric of account.metrics) lines.push(` ${metricText(metric)}`);
92
+ }
93
+ return lines;
94
+ }
package/src/http.ts ADDED
@@ -0,0 +1,62 @@
1
+ export class HttpError extends Error {
2
+ constructor(
3
+ readonly status: number,
4
+ readonly body: string,
5
+ ) {
6
+ super(`HTTP ${status}: ${body.slice(0, 200)}`);
7
+ this.name = "HttpError";
8
+ }
9
+ }
10
+
11
+ export async function fetchJson<T>(
12
+ url: string | URL,
13
+ init: RequestInit & { timeoutMs?: number },
14
+ signal?: AbortSignal,
15
+ ): Promise<T> {
16
+ const { timeoutMs = 15_000, ...rest } = init;
17
+ const controller = new AbortController();
18
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
19
+ const onAbort = () => controller.abort(signal?.reason);
20
+ signal?.addEventListener("abort", onAbort, { once: true });
21
+ try {
22
+ const response = await fetch(url, {
23
+ redirect: "error",
24
+ ...rest,
25
+ signal: AbortSignal.any([rest.signal ?? controller.signal, controller.signal]),
26
+ });
27
+ const text = await response.text();
28
+ if (!response.ok) throw new HttpError(response.status, text);
29
+ try {
30
+ return text ? (JSON.parse(text) as T) : ({} as T);
31
+ } catch {
32
+ throw new Error(`Invalid JSON from ${new URL(url.toString()).origin}`);
33
+ }
34
+ } finally {
35
+ clearTimeout(timer);
36
+ signal?.removeEventListener("abort", onAbort);
37
+ }
38
+ }
39
+
40
+ export function urlOrigin(url: string | undefined): string | undefined {
41
+ if (!url) return undefined;
42
+ try {
43
+ return new URL(url).origin;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ export function urlOnDomain(url: string | undefined, domain: string): boolean {
50
+ const origin = urlOrigin(url);
51
+ if (!origin) return false;
52
+ try {
53
+ return new URL(origin).hostname === domain || new URL(origin).hostname.endsWith(`.${domain}`);
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ export function safeError(error: unknown): string {
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ return message.replace(/[A-Za-z0-9_-]{24,}/g, "…").slice(0, 300);
62
+ }
package/src/match.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+ import { codexAccountId, resolveCredentials } from "./auth.ts";
4
+ import { anthropicAdapter } from "./adapters/anthropic.ts";
5
+ import { basetenAdapter } from "./adapters/baseten.ts";
6
+ import { clineAdapter } from "./adapters/cline.ts";
7
+ import { cliproxyAdapter } from "./adapters/cliproxy.ts";
8
+ import { deepseekAdapter } from "./adapters/deepseek.ts";
9
+ import { fireworksAdapter } from "./adapters/fireworks.ts";
10
+ import { geminiAdapter } from "./adapters/gemini.ts";
11
+ import { githubCopilotAdapter } from "./adapters/github-copilot.ts";
12
+ import { glmAdapter } from "./adapters/glm.ts";
13
+ import { kimiAdapter } from "./adapters/kimi.ts";
14
+ import { minimaxAdapter } from "./adapters/minimax.ts";
15
+ import { openaiCodexAdapter } from "./adapters/openai-codex.ts";
16
+ import { opencodeGoAdapter } from "./adapters/opencode-go.ts";
17
+ import { openrouterAdapter } from "./adapters/openrouter.ts";
18
+ import { vertexAdapter } from "./adapters/vertex.ts";
19
+ import { vercelGatewayAdapter } from "./adapters/vercel.ts";
20
+ import { xaiAdapter } from "./adapters/xai.ts";
21
+ import type { ProviderTarget, UsageAdapter } from "./types.ts";
22
+
23
+ const adapters: readonly UsageAdapter[] = [
24
+ openaiCodexAdapter,
25
+ anthropicAdapter,
26
+ kimiAdapter,
27
+ openrouterAdapter,
28
+ opencodeGoAdapter,
29
+ minimaxAdapter,
30
+ glmAdapter,
31
+ deepseekAdapter,
32
+ xaiAdapter,
33
+ vertexAdapter,
34
+ geminiAdapter,
35
+ vercelGatewayAdapter,
36
+ cliproxyAdapter,
37
+ githubCopilotAdapter,
38
+ fireworksAdapter,
39
+ basetenAdapter,
40
+ clineAdapter,
41
+ ];
42
+
43
+ export function adapterFor(target: ProviderTarget | undefined): UsageAdapter | undefined {
44
+ if (!target) return undefined;
45
+ return adapters.find((adapter) => {
46
+ try {
47
+ return adapter.canHandle(target);
48
+ } catch {
49
+ return false;
50
+ }
51
+ });
52
+ }
53
+
54
+ function targetFor(ctx: ExtensionContext, model: Model<Api>): ProviderTarget {
55
+ const provider = ctx.modelRegistry.getProvider(model.provider);
56
+ return {
57
+ providerId: model.provider,
58
+ model,
59
+ provider,
60
+ baseUrl: provider?.baseUrl ?? model.baseUrl,
61
+ };
62
+ }
63
+
64
+ export function currentTarget(ctx: ExtensionContext): ProviderTarget | undefined {
65
+ return ctx.model ? targetFor(ctx, ctx.model) : undefined;
66
+ }
67
+
68
+ export function configuredTargets(ctx: ExtensionContext): ProviderTarget[] {
69
+ const seen = new Map<string, Model<Api>>();
70
+ for (const model of ctx.modelRegistry.getAll()) {
71
+ if (!seen.has(model.provider)) seen.set(model.provider, model);
72
+ }
73
+ return [...seen.values()].map((model) => targetFor(ctx, model));
74
+ }
75
+
76
+ export async function withCredentials(ctx: ExtensionContext, target: ProviderTarget): Promise<ProviderTarget> {
77
+ const credentials = await resolveCredentials(ctx, target);
78
+ if (target.providerId === "openai-codex" && credentials) {
79
+ const accountId = await codexAccountId(ctx, credentials);
80
+ if (accountId) return { ...target, credentials: { ...credentials, accountId } };
81
+ }
82
+ return { ...target, credentials };
83
+ }
package/src/types.ts ADDED
@@ -0,0 +1,79 @@
1
+ import type { Api, AuthResult, Model, Provider } from "@earendil-works/pi-ai";
2
+
3
+ export type UsageState =
4
+ | "ok"
5
+ | "unauthorized"
6
+ | "unsupported"
7
+ | "empty"
8
+ | "unavailable";
9
+
10
+ export type Metric =
11
+ | { kind: "balance"; id: string; label: string; amount: number; currency: string; detail?: string }
12
+ | { kind: "quota-window"; id: string; label: string; remainingFraction: number; resetAt?: string; detail?: string }
13
+ | { kind: "usage-limit"; id: string; label: string; used: number; limit: number; unit: string; detail?: string }
14
+ | { kind: "status"; id: string; label: string; value: string; detail?: string };
15
+
16
+ export interface UsageAccount {
17
+ id: string;
18
+ label: string;
19
+ metrics: Metric[];
20
+ }
21
+
22
+ export interface UsageSnapshot {
23
+ adapterId: string;
24
+ sourceProviderId: string;
25
+ displayName: string;
26
+ state: UsageState;
27
+ fetchedAt: string;
28
+ accounts: UsageAccount[];
29
+ summary?: string;
30
+ error?: string;
31
+ }
32
+
33
+ export interface Credentials {
34
+ apiKey?: string;
35
+ headers?: Record<string, string>;
36
+ baseUrl?: string;
37
+ accountId?: string;
38
+ source: "model" | "provider" | "stored";
39
+ }
40
+
41
+ export interface ProviderTarget {
42
+ providerId: string;
43
+ model?: Model<Api>;
44
+ provider?: Provider<Api>;
45
+ baseUrl?: string;
46
+ auth?: AuthResult;
47
+ authError?: string;
48
+ credentials?: Credentials;
49
+ }
50
+
51
+ export interface FetchContext {
52
+ target: ProviderTarget;
53
+ signal: AbortSignal;
54
+ force: boolean;
55
+ }
56
+
57
+ export interface UsageAdapter {
58
+ id: string;
59
+ label: string;
60
+ canHandle(target: ProviderTarget): boolean;
61
+ fetch(context: FetchContext): Promise<UsageSnapshot>;
62
+ }
63
+
64
+ export function snapshot(
65
+ adapter: UsageAdapter,
66
+ target: ProviderTarget,
67
+ state: UsageState,
68
+ extra?: Partial<Pick<UsageSnapshot, "accounts" | "error" | "summary">>,
69
+ ): UsageSnapshot {
70
+ return {
71
+ adapterId: adapter.id,
72
+ sourceProviderId: target.providerId,
73
+ displayName: adapter.label,
74
+ state,
75
+ fetchedAt: new Date().toISOString(),
76
+ accounts: [],
77
+ ...extra,
78
+ };
79
+ }