pi-credits 0.1.2 → 0.2.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
@@ -11,11 +11,12 @@ A [pi](https://pi.dev/) extension that shows the active model provider's credit
11
11
  ## Supported providers
12
12
 
13
13
  - DeepSeek
14
+ - Fireworks
14
15
  - OpenAI Codex
15
16
  - OpenRouter
16
17
  - Vercel AI Gateway
17
18
 
18
- The provider-specific fetching approaches are strongly inspired by [CodexBar](https://github.com/steipete/codexbar).
19
+ Most provider-specific fetching approaches references [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).
19
20
 
20
21
  ## Install
21
22
 
@@ -0,0 +1,99 @@
1
+ # Reverse-Engineering Fireworks Credits
2
+
3
+ > This doc is AI-generated by the [pi](https://pi.dev/) coding agent.
4
+
5
+ Most providers in pi-credits expose a documented HTTP endpoint that returns a balance. [Fireworks](https://fireworks.ai/) does not. Its dashboard shows account credit, but there is no public REST route for it. This note records how the balance API was found by reverse-engineering the official `firectl` CLI, and how the provider is implemented on top of it.
6
+
7
+ ## Where the binary came from
8
+
9
+ Fireworks ships a command-line tool, [`firectl`](https://docs.fireworks.ai/tools-sdks/firectl/). The [Homebrew tap formula](https://github.com/fw-ai/homebrew-firectl/blob/main/Formula/firectl.rb) points at per-platform binaries; the `darwin-amd64` build was used here.
10
+
11
+ It is a Go binary, and crucially an **unstripped** one, which keeps the symbol table, the `pclntab` (function/line metadata), and the full module dependency list. None of that yields original source, but it exposes the program's structure in detail.
12
+
13
+ ```sh
14
+ go version -m firectl # build info: firectl 1.7.22, Go 1.25.0, module path
15
+ go tool nm firectl # 81k symbols, including package-qualified names
16
+ go tool objdump -s SYM firectl # disassembly annotated with file:line
17
+ go run github.com/goretk/redress@latest source firectl # folder/file/function tree
18
+ ```
19
+
20
+ ## Locating the credit feature
21
+
22
+ The CLI is a Cobra app that is a thin client over a gRPC service named `gateway.Gateway`. Listing the RPC method paths embedded in the binary surfaced the relevant calls:
23
+
24
+ ```sh
25
+ strings firectl | grep -oE '/gateway\.Gateway/[A-Za-z]+' | sort -u
26
+ # ... /gateway.Gateway/GetBalance
27
+ # ... /gateway.Gateway/GetLedger
28
+ # ... /gateway.Gateway/ListCreditRedemptions
29
+ # ... /gateway.Gateway/ListAccounts
30
+ ```
31
+
32
+ `GetBalance` is exactly what the dashboard shows, but checking the symbol table revealed that **no CLI command is wired to it** — it exists in the gateway client yet is not reachable through any `firectl` subcommand. So the goal became calling it directly.
33
+
34
+ ## Working out authentication
35
+
36
+ Two facts had to be recovered: the host and the credential format.
37
+
38
+ The binary contains several Fireworks hostnames. The inference API (`api.fireworks.ai`) is the well-known one, but the control plane uses a separate host:
39
+
40
+ ```sh
41
+ strings firectl | grep -oE '[a-z0-9.-]+\.fireworks\.ai' | sort -u
42
+ # api.fireworks.ai (inference)
43
+ # gateway.fireworks.ai (control-plane gRPC) <- this one
44
+ # app.fireworks.ai, device-auth.fireworks.ai
45
+ ```
46
+
47
+ For the credential, the disassembly of the client's auth interceptor showed it sets gRPC metadata and references both `authorization` (Bearer) and `x-api-key`. The Bearer slot expects an OIDC ID token, which is what interactive `firectl signin` produces. API-key auth uses the `x-api-key` header instead.
48
+
49
+ This matched what happened on the wire while probing the endpoint with a saved API key:
50
+
51
+ - `api.fireworks.ai:443` returned HTTP 404 — wrong host for this service.
52
+ - `gateway.fireworks.ai:443` with `authorization: Bearer <key>` returned `UNAUTHENTICATED: invalid id token` — the key is not a JWT.
53
+ - `gateway.fireworks.ai:443` with `x-api-key: <key>` authenticated, then returned `INVALID_ARGUMENT` — auth worked; the request body was incomplete.
54
+
55
+ ## Reconstructing the messages
56
+
57
+ The Go protobuf structs carry their field tags as string literals, so the message shapes can be read straight out of the binary:
58
+
59
+ ```sh
60
+ strings firectl | grep -oE 'protobuf:"[^"]*name=(name|money|currency_code|units|nanos)[^"]*"'
61
+ ```
62
+
63
+ That, plus the generated getter names (`(*Balance).GetMoney`, `(*GetBalanceRequest).GetName`), gives:
64
+
65
+ ```proto
66
+ message GetBalanceRequest { string name = 1; } // "accounts/<account_id>"
67
+ message Balance { Money money = 1; }
68
+ message Money { string currency_code = 1; int64 units = 2; int32 nanos = 3; } // google.type.Money
69
+ ```
70
+
71
+ `GetBalance` needs a `name` like `accounts/<account_id>`. Rather than ask the user for their account id, `ListAccounts` (which the same API key can call) returns it:
72
+
73
+ ```proto
74
+ message Account { string name = 1; string display_name = 2; }
75
+ message ListAccountsResponse { repeated Account accounts = 1; }
76
+ ```
77
+
78
+ ## Confirming end to end
79
+
80
+ A minimal Node.js client using `@grpc/grpc-js` and `@grpc/proto-loader` confirmed the full flow against the live service:
81
+
82
+ 1. `ListAccounts` with `x-api-key` → `accounts/<id>`.
83
+ 2. `GetBalance { name }` → `Balance { money: { currency_code, units, nanos } }`.
84
+
85
+ The amount is `units + nanos / 1e9` (the standard `google.type.Money` encoding), e.g., `units=9, nanos=694293840` → `$9.69`.
86
+
87
+ ## How the provider is implemented in pi-credits
88
+
89
+ The provider lives in [`src/providers/fireworks.ts`](../src/providers/fireworks.ts) and follows the same `CreditsProvider` contract as the HTTP-based providers; only the transport differs.
90
+
91
+ - **Transport.** A single, reused `@grpc/grpc-js` client dials `gateway.fireworks.ai:443` over TLS. The service is declared in [`fireworks.proto`](../src/providers/fireworks.proto) — the minimal slice needed, loaded at runtime with `@grpc/proto-loader`. The `.proto` is resolved relative to the module via `import.meta.url`, so it ships and loads from the package directory.
92
+ - **Auth.** Each call sends `x-api-key: <key>` in gRPC metadata. The key comes from pi's `modelRegistry.getApiKeyForProvider("fireworks")` (env `FIREWORKS_API_KEY`), handled by the manager.
93
+ - **Account resolution.** `ListAccounts` is called once and the resolved `accounts/<id>` is cached per API key, so steady-state refreshes are a single `GetBalance` round trip.
94
+ - **Cancellation.** The shared `AbortSignal` cancels the in-flight gRPC call, and a defensive deadline bounds each request.
95
+ - **Result.** `Money` is converted to a dollar amount and returned as `{ type: "balance", remaining }`, which the status line renders like any other balance provider.
96
+
97
+ ## Caveats
98
+
99
+ `GetBalance` is an internal API: it is intentionally absent from `firectl`, undocumented, and not part of any public contract. Its host, request shape, and behavior can change at any time without notice, which would break this provider. The narrow `.proto` keeps the blast radius small if that happens.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-credits",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "A pi extension that shows the active provider's credit balance or rate-limit usage",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
@@ -15,6 +15,7 @@
15
15
  "files": [
16
16
  "index.ts",
17
17
  "src",
18
+ "docs",
18
19
  "assets",
19
20
  "README.md",
20
21
  "LICENSE"
@@ -28,6 +29,10 @@
28
29
  "scripts": {
29
30
  "typecheck": "tsc --noEmit"
30
31
  },
32
+ "dependencies": {
33
+ "@grpc/grpc-js": "^1.14.4",
34
+ "@grpc/proto-loader": "^0.8.1"
35
+ },
31
36
  "peerDependencies": {
32
37
  "@earendil-works/pi-agent-core": "*",
33
38
  "@earendil-works/pi-ai": "*",
@@ -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
+ provider: 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
+ };
@@ -1,4 +1,5 @@
1
1
  import { deepseekProvider } from "./deepseek";
2
+ import { fireworksProvider } from "./fireworks";
2
3
  import { openaiCodexProvider } from "./openai-codex";
3
4
  import { openrouterProvider } from "./openrouter";
4
5
  import { vercelAiGatewayProvider } from "./vercel-ai-gateway";
@@ -7,6 +8,7 @@ import type { CreditsProvider } from "../types";
7
8
 
8
9
  const PROVIDERS: CreditsProvider[] = [
9
10
  deepseekProvider,
11
+ fireworksProvider,
10
12
  openaiCodexProvider,
11
13
  openrouterProvider,
12
14
  vercelAiGatewayProvider,