pi-spark 0.11.1 → 0.12.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/README.md CHANGED
@@ -1,9 +1,12 @@
1
+ ![Cover](./assets/cover.png)
2
+
1
3
  # pi-spark
2
4
 
3
- A collection of [pi](https://pi.dev/) extensions that polish the daily pi experience.
5
+ [Pi](https://pi.dev/) extensions that polish the daily experience.
4
6
 
5
7
  ## Extensions
6
8
 
9
+ - **Credits:** shows the active provider's credit balance or rate-limit usage in the status line.
7
10
  - **Editor:** replaces the default editor with a compact working indicator (inspired by [Amp](https://ampcode.com/)) and current model info.
8
11
  - **Footer:** shows session information, extension statuses, cost, and context usage on one line.
9
12
  - **Fullscreen:** clears the screen and scrollback on session start, pins the editor and footer to the bottom for a full-screen session, and clears again on exit.
@@ -22,10 +25,10 @@ Install from npm:
22
25
  pi install npm:pi-spark
23
26
  ```
24
27
 
25
- For local development from this monorepo:
28
+ For local development from this repo:
26
29
 
27
30
  ```bash
28
- pi install /path/to/pi-packages/packages/pi-spark
31
+ pi install /path/to/pi-spark
29
32
  ```
30
33
 
31
34
  ## Configure
@@ -38,6 +41,7 @@ Example:
38
41
 
39
42
  ```json
40
43
  {
44
+ "credits": false,
41
45
  "editor": {
42
46
  "spinner": "dots"
43
47
  },
@@ -63,6 +67,12 @@ Example:
63
67
  }
64
68
  ```
65
69
 
70
+ ### Credits
71
+
72
+ - pi-spark shows the active provider's credit balance or rate-limit usage in the status line.
73
+ - Supported providers: DeepSeek, Fireworks, Moonshot, OpenAI Codex, OpenRouter, and Vercel AI Gateway.
74
+ - Most provider-specific fetching approaches reference [CodexBar](https://github.com/steipete/codexbar). Fireworks is an exception: its balance lives behind an internal gRPC API, so the approach was reverse-engineered from the `firectl` binary — see [docs/fireworks.md](./docs/fireworks.md).
75
+
66
76
  ### Editor
67
77
 
68
78
  - `editor.spinner` controls the working indicator style and can be `dots`, `lights`, `tildes`, or `pulse`.
@@ -112,7 +122,3 @@ Use presets in these ways:
112
122
  ```
113
123
 
114
124
  Project trust is an [input-loading guard](https://pi.dev/docs/latest/security#project-trust), so use `"always"` only if you trust the projects you open.
115
-
116
- ## Other pi packages
117
-
118
- See my [pi-packages](https://github.com/zlliang/pi-packages) repo for the full list.
Binary file
Binary file
@@ -0,0 +1,3 @@
1
+ import * as z from "zod";
2
+
3
+ export const creditsConfigSchema = z.object({});
@@ -0,0 +1,51 @@
1
+ import { CreditsManager } from "./manager";
2
+ import { isUsage } from "./utils";
3
+ import { loadConfig } from "../shared/config";
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
7
+
8
+ function hasCost(message: AgentMessage): boolean {
9
+ const usage = (message as { usage?: unknown }).usage;
10
+ if (!isUsage(usage)) return false;
11
+
12
+ return usage.cost.total > 0 || usage.input > 0 || usage.output > 0;
13
+ }
14
+
15
+ export default function (pi: ExtensionAPI) {
16
+ let creditsManager: CreditsManager | undefined = undefined;
17
+
18
+ pi.on("session_start", (_event, ctx) => {
19
+ if (!ctx.hasUI) return;
20
+
21
+ const config = loadConfig(ctx, "credits");
22
+ if (!config) return;
23
+
24
+ creditsManager = new CreditsManager();
25
+ creditsManager.refresh(ctx);
26
+ });
27
+
28
+ pi.on("model_select", (_event, ctx) => {
29
+ creditsManager?.refresh(ctx);
30
+ });
31
+
32
+ pi.on("turn_end", (event, ctx) => {
33
+ if (!hasCost(event.message)) return;
34
+
35
+ creditsManager?.refresh(ctx);
36
+ });
37
+
38
+ pi.on("session_compact", (_event, ctx) => {
39
+ creditsManager?.refresh(ctx);
40
+ });
41
+
42
+ pi.on("session_tree", (event, ctx) => {
43
+ if (!event.summaryEntry) return;
44
+
45
+ creditsManager?.refresh(ctx);
46
+ });
47
+
48
+ pi.on("session_shutdown", () => {
49
+ creditsManager = undefined;
50
+ });
51
+ }
@@ -0,0 +1,64 @@
1
+ import { findProvider } from "./providers";
2
+ import { renderCredits, renderError } from "./status";
3
+
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { CreditsProvider } from "./types";
6
+
7
+ const STATUS_KEY = "credits";
8
+ const REQUEST_TIMEOUT_MS = 30_000;
9
+
10
+ export class CreditsManager {
11
+ private inflight: AbortController | undefined = undefined;
12
+ private currentProvider: string | undefined = undefined;
13
+
14
+ async refresh(ctx: ExtensionContext): Promise<void> {
15
+ this.inflight?.abort();
16
+
17
+ const provider = findProvider(ctx.model?.provider);
18
+ if (!provider) {
19
+ this.inflight = undefined;
20
+ this.currentProvider = undefined;
21
+ ctx.ui.setStatus(STATUS_KEY, undefined);
22
+ return;
23
+ }
24
+
25
+ // Clear stale credits from another provider while the new fetch is in flight.
26
+ if (this.currentProvider !== provider.id) {
27
+ this.currentProvider = provider.id;
28
+ ctx.ui.setStatus(STATUS_KEY, undefined);
29
+ }
30
+
31
+ const controller = new AbortController();
32
+ this.inflight = controller;
33
+
34
+ await this.fetch(ctx, provider, controller.signal).finally(() => {
35
+ if (this.inflight === controller) this.inflight = undefined;
36
+ });
37
+ }
38
+
39
+ private async fetch(ctx: ExtensionContext, provider: CreditsProvider, signal: AbortSignal): Promise<void> {
40
+ try {
41
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider.id);
42
+ if (!apiKey) {
43
+ ctx.ui.setStatus(STATUS_KEY, undefined);
44
+ return;
45
+ }
46
+
47
+ const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal];
48
+ if (ctx.signal) signals.push(ctx.signal);
49
+
50
+ const credits = await provider.fetch(ctx, apiKey, AbortSignal.any(signals));
51
+
52
+ // The active model may have changed while the request was in flight.
53
+ if (ctx.model?.provider !== provider.id) return;
54
+
55
+ ctx.ui.setStatus(STATUS_KEY, renderCredits(ctx.ui.theme, provider.label, credits));
56
+ } catch (error) {
57
+ if (signal.aborted || ctx.signal?.aborted) return;
58
+ if (ctx.model?.provider !== provider.id) return;
59
+
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ ctx.ui.setStatus(STATUS_KEY, renderError(ctx.ui.theme, provider.label, message));
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,36 @@
1
+ import { convertToUSD, toNumber } from "../utils";
2
+
3
+ import type { Credits, CreditsProvider } from "../types";
4
+
5
+ const PROVIDER = "deepseek";
6
+ const URL = "https://api.deepseek.com/user/balance";
7
+
8
+ interface DeepSeekBalanceResponse {
9
+ balance_infos?: DeepSeekBalanceInfo[] | null;
10
+ }
11
+
12
+ interface DeepSeekBalanceInfo {
13
+ currency?: string;
14
+ total_balance?: string | number;
15
+ }
16
+
17
+ export const deepseekProvider: CreditsProvider = {
18
+ id: PROVIDER,
19
+ label: "DeepSeek",
20
+
21
+ async fetch(_ctx, apiKey, signal): Promise<Credits> {
22
+ const headers: Record<string, string> = {
23
+ Accept: "application/json",
24
+ Authorization: `Bearer ${apiKey}`,
25
+ };
26
+
27
+ const response = await fetch(URL, { headers, signal });
28
+ if (!response.ok) throw new Error("request failed");
29
+
30
+ const payload = (await response.json()) as DeepSeekBalanceResponse;
31
+ const balance = payload.balance_infos?.find((entry) => entry.currency === "USD") ?? payload.balance_infos?.[0];
32
+ const remaining = await convertToUSD(toNumber(balance?.total_balance), balance?.currency, signal);
33
+
34
+ return { type: "balance", remaining };
35
+ },
36
+ };
@@ -0,0 +1,42 @@
1
+ syntax = "proto3";
2
+
3
+ // Minimal slice of the Fireworks control-plane gateway API (`gateway.Gateway`), reconstructed from
4
+ // the `firectl` binary. Only the messages and RPCs needed to read an account's credit balance are
5
+ // declared here.
6
+ package gateway;
7
+
8
+ // Mirrors google.type.Money.
9
+ message Money {
10
+ string currency_code = 1;
11
+ int64 units = 2;
12
+ int32 nanos = 3;
13
+ }
14
+
15
+ message Balance {
16
+ Money money = 1;
17
+ }
18
+
19
+ message GetBalanceRequest {
20
+ string name = 1; // resource name, e.g., "accounts/<account_id>"
21
+ }
22
+
23
+ message Account {
24
+ string name = 1; // resource name, e.g., "accounts/<account_id>"
25
+ string display_name = 2;
26
+ }
27
+
28
+ message ListAccountsRequest {
29
+ int32 page_size = 1;
30
+ string page_token = 2;
31
+ }
32
+
33
+ message ListAccountsResponse {
34
+ repeated Account accounts = 1;
35
+ string next_page_token = 2;
36
+ int32 total_size = 3;
37
+ }
38
+
39
+ service Gateway {
40
+ rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
41
+ rpc GetBalance(GetBalanceRequest) returns (Balance);
42
+ }
@@ -0,0 +1,111 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ import { credentials, loadPackageDefinition, Metadata } from "@grpc/grpc-js";
4
+ import { loadSync } from "@grpc/proto-loader";
5
+
6
+ import { toNumber } from "../utils";
7
+
8
+ import type { ClientUnaryCall, ServiceClientConstructor, ServiceError } from "@grpc/grpc-js";
9
+ import type { Credits, CreditsProvider } from "../types";
10
+
11
+ type GatewayClient = InstanceType<ServiceClientConstructor>;
12
+
13
+ const PROVIDER = "fireworks";
14
+
15
+ /**
16
+ * The control-plane gateway is a gRPC service, distinct from the inference API at
17
+ * `api.fireworks.ai`. Credit balance lives here, behind the `x-api-key` header.
18
+ */
19
+ const TARGET = "gateway.fireworks.ai:443";
20
+ const DEADLINE_MS = 20_000;
21
+
22
+ interface Money {
23
+ currency_code?: string;
24
+ units?: string | number;
25
+ nanos?: string | number;
26
+ }
27
+
28
+ interface Balance {
29
+ money?: Money | null;
30
+ }
31
+
32
+ interface Account {
33
+ name?: string;
34
+ }
35
+
36
+ interface ListAccountsResponse {
37
+ accounts?: Account[] | null;
38
+ }
39
+
40
+ /** gRPC clients are reusable and multiplex over one connection, so build once. */
41
+ let client: GatewayClient | undefined;
42
+
43
+ /** The API key maps to a fixed account, so cache the resolved resource name. */
44
+ const accountByKey = new Map<string, string>();
45
+
46
+ function getClient(): GatewayClient {
47
+ if (client) return client;
48
+
49
+ const protoPath = fileURLToPath(new URL("./fireworks.proto", import.meta.url));
50
+ const definition = loadSync(protoPath, { keepCase: true, longs: String, defaults: true });
51
+ const proto = loadPackageDefinition(definition) as unknown as { gateway: { Gateway: ServiceClientConstructor } };
52
+ client = new proto.gateway.Gateway(TARGET, credentials.createSsl());
53
+
54
+ return client;
55
+ }
56
+
57
+ async function unary<T>(method: string, request: object, apiKey: string, signal: AbortSignal): Promise<T> {
58
+ return new Promise<T>((resolve, reject) => {
59
+ const metadata = new Metadata();
60
+ metadata.set("x-api-key", apiKey);
61
+
62
+ const deadline = new Date(Date.now() + DEADLINE_MS);
63
+ const gateway = getClient();
64
+ const invoke = gateway[method] as (
65
+ request: object,
66
+ metadata: Metadata,
67
+ options: { deadline: Date },
68
+ callback: (error: ServiceError | null, response: T) => void,
69
+ ) => ClientUnaryCall;
70
+
71
+ const call = invoke.call(gateway, request, metadata, { deadline }, (error, response) => {
72
+ if (error) reject(new Error(error.details || error.message));
73
+ else resolve(response);
74
+ });
75
+
76
+ if (signal.aborted) call.cancel();
77
+ else signal.addEventListener("abort", () => call.cancel(), { once: true });
78
+ });
79
+ }
80
+
81
+ async function resolveAccount(apiKey: string, signal: AbortSignal): Promise<string> {
82
+ const cached = accountByKey.get(apiKey);
83
+ if (cached) return cached;
84
+
85
+ const response = await unary<ListAccountsResponse>("ListAccounts", {}, apiKey, signal);
86
+ const name = response.accounts?.[0]?.name;
87
+ if (!name) throw new Error("no account found");
88
+
89
+ accountByKey.set(apiKey, name);
90
+ return name;
91
+ }
92
+
93
+ function moneyToNumber(money: Money | null | undefined): number | undefined {
94
+ if (!money) return undefined;
95
+
96
+ const units = toNumber(money.units) ?? 0;
97
+ const nanos = toNumber(money.nanos) ?? 0;
98
+ return units + nanos / 1e9;
99
+ }
100
+
101
+ export const fireworksProvider: CreditsProvider = {
102
+ id: PROVIDER,
103
+ label: "Fireworks",
104
+
105
+ async fetch(_ctx, apiKey, signal): Promise<Credits> {
106
+ const name = await resolveAccount(apiKey, signal);
107
+ const balance = await unary<Balance>("GetBalance", { name }, apiKey, signal);
108
+
109
+ return { type: "balance", remaining: moneyToNumber(balance.money) };
110
+ },
111
+ };
@@ -0,0 +1,22 @@
1
+ import { deepseekProvider } from "./deepseek";
2
+ import { fireworksProvider } from "./fireworks";
3
+ import { moonshotProvider, moonshotCnProvider } from "./moonshot";
4
+ import { openaiCodexProvider } from "./openai-codex";
5
+ import { openrouterProvider } from "./openrouter";
6
+ import { vercelAiGatewayProvider } from "./vercel-ai-gateway";
7
+
8
+ import type { CreditsProvider } from "../types";
9
+
10
+ const PROVIDERS: CreditsProvider[] = [
11
+ deepseekProvider,
12
+ fireworksProvider,
13
+ moonshotProvider,
14
+ moonshotCnProvider,
15
+ openaiCodexProvider,
16
+ openrouterProvider,
17
+ vercelAiGatewayProvider,
18
+ ];
19
+
20
+ export function findProvider(provider?: string): CreditsProvider | undefined {
21
+ return PROVIDERS.find((entry) => entry.id === provider);
22
+ }
@@ -0,0 +1,39 @@
1
+ import { convertToUSD, toNumber } from "../utils";
2
+
3
+ import type { Credits, CreditsProvider } from "../types";
4
+
5
+ interface MoonshotBalanceResponse {
6
+ data?: {
7
+ available_balance?: string | number;
8
+ } | null;
9
+ }
10
+
11
+ /**
12
+ * For Moonshot, the international and China-mainland accounts live on separate hosts and bill in
13
+ * different currencies (USD vs CNY), which the endpoint does not report, so each pi provider ID
14
+ * fixes both host and currency.
15
+ */
16
+ function createMoonshotProvider(id: string, host: string, currency: string): CreditsProvider {
17
+ return {
18
+ id,
19
+ label: "Moonshot",
20
+
21
+ async fetch(_ctx, apiKey, signal): Promise<Credits> {
22
+ const headers: Record<string, string> = {
23
+ Accept: "application/json",
24
+ Authorization: `Bearer ${apiKey}`,
25
+ };
26
+
27
+ const response = await fetch(`https://${host}/v1/users/me/balance`, { headers, signal });
28
+ if (!response.ok) throw new Error("request failed");
29
+
30
+ const payload = (await response.json()) as MoonshotBalanceResponse;
31
+ const remaining = await convertToUSD(toNumber(payload.data?.available_balance), currency, signal);
32
+
33
+ return { type: "balance", remaining };
34
+ },
35
+ };
36
+ }
37
+
38
+ export const moonshotProvider = createMoonshotProvider("moonshotai", "api.moonshot.ai", "USD");
39
+ export const moonshotCnProvider = createMoonshotProvider("moonshotai-cn", "api.moonshot.cn", "CNY");
@@ -0,0 +1,62 @@
1
+ import { toNumber } from "../utils";
2
+
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { Credits, CreditsProvider } from "../types";
5
+
6
+ const PROVIDER = "openai-codex";
7
+ const URL = "https://chatgpt.com/backend-api/wham/usage";
8
+
9
+ interface CodexUsageResponse {
10
+ rate_limit?: {
11
+ primary_window?: CodexRateWindow | null;
12
+ secondary_window?: CodexRateWindow | null;
13
+ } | null;
14
+ credits?: {
15
+ unlimited?: boolean;
16
+ } | null;
17
+ }
18
+
19
+ interface CodexRateWindow {
20
+ used_percent?: number | string;
21
+ }
22
+
23
+ export function getAccountId(ctx: ExtensionContext): string | undefined {
24
+ const credential = ctx.modelRegistry.authStorage.get(PROVIDER) as { accountId?: string } | undefined;
25
+ const accountId = credential?.accountId;
26
+
27
+ return typeof accountId === "string" && accountId.trim() ? accountId.trim() : undefined;
28
+ }
29
+
30
+ function parseUsedPercent(window?: CodexRateWindow | null): number | undefined {
31
+ const value = toNumber(window?.used_percent);
32
+ return typeof value === "number" ? Math.min(100, Math.max(0, value)) : undefined;
33
+ }
34
+
35
+ export const openaiCodexProvider: CreditsProvider = {
36
+ id: PROVIDER,
37
+ label: "Codex",
38
+
39
+ async fetch(ctx, apiKey, signal): Promise<Credits> {
40
+ const headers: Record<string, string> = {
41
+ Accept: "application/json",
42
+ Authorization: `Bearer ${apiKey}`,
43
+ };
44
+
45
+ const accountId = getAccountId(ctx);
46
+ if (accountId) headers["ChatGPT-Account-Id"] = accountId;
47
+
48
+ const response = await fetch(URL, { headers, signal });
49
+ if (!response.ok) throw new Error("request failed");
50
+
51
+ const payload = (await response.json()) as CodexUsageResponse;
52
+
53
+ return {
54
+ type: "windows",
55
+ unlimited: payload.credits?.unlimited === true,
56
+ lanes: [
57
+ { label: "5h", percent: parseUsedPercent(payload.rate_limit?.primary_window) },
58
+ { label: "7d", percent: parseUsedPercent(payload.rate_limit?.secondary_window) },
59
+ ],
60
+ };
61
+ },
62
+ };
@@ -0,0 +1,35 @@
1
+ import { toNumber } from "../utils";
2
+
3
+ import type { Credits, CreditsProvider } from "../types";
4
+
5
+ const PROVIDER = "openrouter";
6
+ const URL = "https://openrouter.ai/api/v1/credits";
7
+
8
+ interface OpenRouterCreditsResponse {
9
+ data?: {
10
+ total_credits?: number | null;
11
+ total_usage?: number | null;
12
+ } | null;
13
+ }
14
+
15
+ export const openrouterProvider: CreditsProvider = {
16
+ id: PROVIDER,
17
+ label: "OpenRouter",
18
+
19
+ async fetch(_ctx, apiKey, signal): Promise<Credits> {
20
+ const headers: Record<string, string> = {
21
+ Accept: "application/json",
22
+ Authorization: `Bearer ${apiKey}`,
23
+ };
24
+
25
+ const response = await fetch(URL, { headers, signal });
26
+ if (!response.ok) throw new Error("request failed");
27
+
28
+ const payload = (await response.json()) as OpenRouterCreditsResponse;
29
+ const totalCredits = toNumber(payload.data?.total_credits);
30
+ const totalUsage = toNumber(payload.data?.total_usage);
31
+ const remaining = typeof totalCredits === "number" && typeof totalUsage === "number" ? totalCredits - totalUsage : undefined;
32
+
33
+ return { type: "balance", remaining };
34
+ },
35
+ };
@@ -0,0 +1,30 @@
1
+ import { toNumber } from "../utils";
2
+
3
+ import type { Credits, CreditsProvider } from "../types";
4
+
5
+ const PROVIDER = "vercel-ai-gateway";
6
+ const URL = "https://ai-gateway.vercel.sh/v1/credits";
7
+
8
+ interface VercelCreditsResponse {
9
+ balance?: string | number;
10
+ }
11
+
12
+ export const vercelAiGatewayProvider: CreditsProvider = {
13
+ id: PROVIDER,
14
+ label: "Vercel",
15
+
16
+ async fetch(_ctx, apiKey, signal): Promise<Credits> {
17
+ const headers: Record<string, string> = {
18
+ Accept: "application/json",
19
+ Authorization: `Bearer ${apiKey}`,
20
+ };
21
+
22
+ const response = await fetch(URL, { headers, signal });
23
+ if (!response.ok) throw new Error("request failed");
24
+
25
+ const payload = (await response.json()) as VercelCreditsResponse;
26
+ const remaining = toNumber(payload.balance);
27
+
28
+ return { type: "balance", remaining };
29
+ },
30
+ };
@@ -0,0 +1,43 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Credits, CreditsLane } from "./types";
3
+
4
+ const WINDOWS_WARNING = 70;
5
+ const WINDOWS_ERROR = 90;
6
+ const BALANCE_WARNING = 10;
7
+ const BALANCE_ERROR = 5;
8
+
9
+ export function renderCredits(theme: Theme, label: string, credits: Credits): string {
10
+ const styledLabel = theme.fg("dim", label);
11
+
12
+ if (credits.type === "windows") return `${styledLabel} ${renderWindows(theme, credits)}`;
13
+ return `${styledLabel} ${renderBalance(theme, credits)}`;
14
+ }
15
+
16
+ export function renderError(theme: Theme, label: string, message: string): string {
17
+ return theme.fg("error", `${label} credits unavailable: ${message}`);
18
+ }
19
+
20
+ function renderWindows(theme: Theme, credits: Extract<Credits, { type: "windows" }>): string {
21
+ const unlimited = credits.unlimited || credits.lanes.every((lane) => lane.percent === undefined);
22
+ if (unlimited) return theme.fg("success", "unlimited");
23
+
24
+ return credits.lanes.map((lane) => renderLane(theme, lane)).join(" ");
25
+ }
26
+
27
+ function renderLane(theme: Theme, lane: CreditsLane): string {
28
+ const text = `${lane.label} ${lane.percent === undefined ? "?" : lane.percent.toFixed(0)}%`;
29
+
30
+ if (lane.percent && lane.percent > WINDOWS_ERROR) return theme.fg("error", text);
31
+ if (lane.percent && lane.percent > WINDOWS_WARNING) return theme.fg("warning", text);
32
+ return theme.fg("success", text);
33
+ }
34
+
35
+ function renderBalance(theme: Theme, credits: Extract<Credits, { type: "balance" }>): string {
36
+ if (credits.remaining === undefined) return theme.fg("dim", "$?");
37
+
38
+ const text = `$${credits.remaining.toFixed(2)}`;
39
+
40
+ if (credits.remaining < BALANCE_ERROR) return theme.fg("error", text);
41
+ if (credits.remaining < BALANCE_WARNING) return theme.fg("warning", text);
42
+ return theme.fg("success", text);
43
+ }
@@ -0,0 +1,24 @@
1
+ import type { Provider } from "@earendil-works/pi-ai";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ /**
5
+ * Normalized credits/usage for a provider.
6
+ *
7
+ * - `balance` providers (e.g., OpenRouter, Vercel AI Gateway) report a remaining dollar balance.
8
+ * - `windows` providers (e.g., OpenAI Codex) report rate-limit windows as used percentages.
9
+ */
10
+ export type Credits =
11
+ | { type: "balance"; remaining?: number | undefined }
12
+ | { type: "windows"; lanes: CreditsLane[]; unlimited?: boolean | undefined };
13
+
14
+ export interface CreditsLane {
15
+ label: string;
16
+ percent: number | undefined;
17
+ }
18
+
19
+ /** A credits source for a pi provider, shown in the status line while that provider is active. */
20
+ export interface CreditsProvider {
21
+ readonly id: Provider;
22
+ readonly label: string;
23
+ fetch(ctx: ExtensionContext, apiKey: string, signal: AbortSignal): Promise<Credits>;
24
+ }
@@ -0,0 +1,52 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+
3
+ /** Structural type guard for the pi `Usage` shape. */
4
+ export function isUsage(value: unknown): value is Usage {
5
+ if (typeof value !== "object" || value === null) return false;
6
+
7
+ const usage = value as Record<string, unknown>;
8
+ const hasTokenFields =
9
+ typeof usage.input === "number" &&
10
+ typeof usage.output === "number" &&
11
+ typeof usage.cacheRead === "number" &&
12
+ typeof usage.cacheWrite === "number" &&
13
+ typeof usage.totalTokens === "number";
14
+ if (!hasTokenFields) return false;
15
+
16
+ if (typeof usage.cost !== "object" || usage.cost === null) return false;
17
+ const cost = usage.cost as Record<string, unknown>;
18
+ return (
19
+ typeof cost.input === "number" &&
20
+ typeof cost.output === "number" &&
21
+ typeof cost.cacheRead === "number" &&
22
+ typeof cost.cacheWrite === "number" &&
23
+ typeof cost.total === "number"
24
+ );
25
+ }
26
+
27
+ export function toNumber(value?: string | number | null): number | undefined {
28
+ if (value === undefined || value === null) return undefined;
29
+ const parsed = typeof value === "number" ? value : Number(value);
30
+ return Number.isFinite(parsed) ? parsed : undefined;
31
+ }
32
+
33
+ const FRANKFURTER_API = "https://api.frankfurter.dev/v2/rate";
34
+
35
+ interface FrankfurterRateResponse {
36
+ rate?: string | number;
37
+ }
38
+
39
+ export async function convertToUSD(amount: number | undefined, currency: string | undefined, signal: AbortSignal): Promise<number | undefined> {
40
+ if (amount === undefined) return undefined;
41
+ if (!currency || currency === "USD") return amount;
42
+
43
+ const url = `${FRANKFURTER_API}/${encodeURIComponent(currency)}/USD`;
44
+ const response = await fetch(url, { headers: { Accept: "application/json" }, signal });
45
+ if (!response.ok) throw new Error("currency conversion failed");
46
+
47
+ const payload = (await response.json()) as FrankfurterRateResponse;
48
+ const rate = toNumber(payload.rate);
49
+ if (rate === undefined) throw new Error("currency conversion failed");
50
+
51
+ return amount * rate;
52
+ }
@@ -1,5 +1,6 @@
1
1
  import * as z from "zod";
2
2
 
3
+ import { creditsConfigSchema } from "../../credits/config";
3
4
  import { editorConfigSchema } from "../../editor/config";
4
5
  import { footerConfigSchema } from "../../footer/config";
5
6
  import { fullscreenConfigSchema } from "../../fullscreen/config";
@@ -9,6 +10,7 @@ import { presetsConfigSchema } from "../../presets/config";
9
10
  import { recapConfigSchema } from "../../recap/config";
10
11
 
11
12
  export const configSchemas = {
13
+ credits: creditsConfigSchema,
12
14
  editor: editorConfigSchema,
13
15
  footer: footerConfigSchema,
14
16
  fullscreen: fullscreenConfigSchema,
package/package.json CHANGED
@@ -1,30 +1,35 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.11.1",
4
- "description": "A collection of pi extensions that polish the daily pi experience.",
3
+ "version": "0.12.0",
4
+ "description": "Pi extensions that polish the daily experience.",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
7
7
  "pi-package"
8
8
  ],
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/zlliang/pi-packages.git",
12
- "directory": "packages/pi-spark"
11
+ "url": "git+https://github.com/zlliang/pi-spark.git"
13
12
  },
14
13
  "license": "MIT",
15
14
  "type": "module",
16
15
  "files": [
17
16
  "extensions",
18
17
  "assets",
19
- "README.md"
18
+ "README.md",
19
+ "LICENSE"
20
20
  ],
21
21
  "pi": {
22
22
  "extensions": [
23
23
  "./extensions"
24
24
  ],
25
- "image": "https://raw.githubusercontent.com/zlliang/pi-packages/main/assets/cover.png"
25
+ "image": "https://raw.githubusercontent.com/zlliang/pi-spark/main/assets/cover.png"
26
+ },
27
+ "scripts": {
28
+ "typecheck": "tsc --noEmit"
26
29
  },
27
30
  "dependencies": {
31
+ "@grpc/grpc-js": "^1.14.4",
32
+ "@grpc/proto-loader": "^0.8.1",
28
33
  "liqe": "^3.8.7",
29
34
  "ms": "4.0.0-nightly.202508271359",
30
35
  "zod": "^4.4.3"
@@ -35,5 +40,14 @@
35
40
  "@earendil-works/pi-coding-agent": "*",
36
41
  "@earendil-works/pi-tui": "*",
37
42
  "typebox": "*"
43
+ },
44
+ "devDependencies": {
45
+ "@earendil-works/pi-agent-core": "*",
46
+ "@earendil-works/pi-ai": "*",
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*",
49
+ "@types/node": "*",
50
+ "typebox": "*",
51
+ "typescript": "^6.0.3"
38
52
  }
39
- }
53
+ }