pi-credits 0.1.2 → 0.3.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 +3 -1
- package/docs/fireworks.md +99 -0
- package/package.json +6 -1
- package/src/providers/deepseek.ts +1 -21
- package/src/providers/fireworks.proto +42 -0
- package/src/providers/fireworks.ts +111 -0
- package/src/providers/index.ts +5 -0
- package/src/providers/moonshot.ts +39 -0
- package/src/utils.ts +21 -0
package/README.md
CHANGED
|
@@ -11,11 +11,13 @@ 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
|
|
15
|
+
- Moonshot
|
|
14
16
|
- OpenAI Codex
|
|
15
17
|
- OpenRouter
|
|
16
18
|
- Vercel AI Gateway
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
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
21
|
|
|
20
22
|
## Install
|
|
21
23
|
|
|
@@ -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.
|
|
3
|
+
"version": "0.3.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": "*",
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { toNumber } from "../utils";
|
|
1
|
+
import { convertToUSD, toNumber } from "../utils";
|
|
2
2
|
|
|
3
3
|
import type { Credits, CreditsProvider } from "../types";
|
|
4
4
|
|
|
5
5
|
const PROVIDER = "deepseek";
|
|
6
6
|
const URL = "https://api.deepseek.com/user/balance";
|
|
7
|
-
const FRANKFURTER_API = "https://api.frankfurter.dev/v2/rate";
|
|
8
7
|
|
|
9
8
|
interface DeepSeekBalanceResponse {
|
|
10
9
|
balance_infos?: DeepSeekBalanceInfo[] | null;
|
|
@@ -15,25 +14,6 @@ interface DeepSeekBalanceInfo {
|
|
|
15
14
|
total_balance?: string | number;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
interface FrankfurterRateResponse {
|
|
19
|
-
rate?: string | number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function convertToUSD(amount: number | undefined, currency: string | undefined, signal: AbortSignal): Promise<number | undefined> {
|
|
23
|
-
if (amount === undefined) return undefined;
|
|
24
|
-
if (!currency || currency === "USD") return amount;
|
|
25
|
-
|
|
26
|
-
const url = `${FRANKFURTER_API}/${encodeURIComponent(currency)}/USD`;
|
|
27
|
-
const response = await fetch(url, { headers: { Accept: "application/json" }, signal });
|
|
28
|
-
if (!response.ok) throw new Error("currency conversion failed");
|
|
29
|
-
|
|
30
|
-
const payload = (await response.json()) as FrankfurterRateResponse;
|
|
31
|
-
const rate = toNumber(payload.rate);
|
|
32
|
-
if (rate === undefined) throw new Error("currency conversion failed");
|
|
33
|
-
|
|
34
|
-
return amount * rate;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
17
|
export const deepseekProvider: CreditsProvider = {
|
|
38
18
|
provider: PROVIDER,
|
|
39
19
|
label: "DeepSeek",
|
|
@@ -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
|
+
};
|
package/src/providers/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { deepseekProvider } from "./deepseek";
|
|
2
|
+
import { fireworksProvider } from "./fireworks";
|
|
3
|
+
import { moonshotProvider, moonshotCnProvider } from "./moonshot";
|
|
2
4
|
import { openaiCodexProvider } from "./openai-codex";
|
|
3
5
|
import { openrouterProvider } from "./openrouter";
|
|
4
6
|
import { vercelAiGatewayProvider } from "./vercel-ai-gateway";
|
|
@@ -7,6 +9,9 @@ import type { CreditsProvider } from "../types";
|
|
|
7
9
|
|
|
8
10
|
const PROVIDERS: CreditsProvider[] = [
|
|
9
11
|
deepseekProvider,
|
|
12
|
+
fireworksProvider,
|
|
13
|
+
moonshotProvider,
|
|
14
|
+
moonshotCnProvider,
|
|
10
15
|
openaiCodexProvider,
|
|
11
16
|
openrouterProvider,
|
|
12
17
|
vercelAiGatewayProvider,
|
|
@@ -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(provider: string, host: string, currency: string): CreditsProvider {
|
|
17
|
+
return {
|
|
18
|
+
provider,
|
|
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");
|
package/src/utils.ts
CHANGED
|
@@ -29,3 +29,24 @@ export function toNumber(value?: string | number | null): number | undefined {
|
|
|
29
29
|
const parsed = typeof value === "number" ? value : Number(value);
|
|
30
30
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
31
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
|
+
}
|