@flame0510/project-aether 1.9.1 → 1.11.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 -3
- package/app/agents/CostSection.tsx +94 -0
- package/app/agents/PageClient.tsx +14 -0
- package/app/agents/PanelRow.tsx +9 -0
- package/app/agents/ResourceSection.tsx +105 -0
- package/app/api/assistant/route.ts +10 -3
- package/app/api/containers/route.ts +18 -2
- package/app/api/costs/agent/route.ts +55 -0
- package/app/api/costs/route.ts +19 -77
- package/app/api/costs/usage/route.ts +65 -0
- package/app/api/gateway/sync.ts +18 -4
- package/app/api/metrics/alerts/route.ts +47 -0
- package/app/api/metrics/containers/route.ts +54 -0
- package/app/api/metrics/route.ts +4 -6
- package/app/api/stats/route.ts +2 -3
- package/app/api/stats-since/route.ts +1 -1
- package/app/api/stream/route.ts +3 -5
- package/app/api/system-health/route.ts +33 -32
- package/app/components/CostBreakdown.tsx +1 -1
- package/app/components/Sidebar.tsx +10 -0
- package/app/components/SystemCockpit.tsx +1 -1
- package/app/components/ui/Accordion.tsx +45 -0
- package/app/components/ui/TimeSeriesChart.tsx +47 -11
- package/app/components/ui/index.ts +1 -0
- package/app/containers/ContainersClient.tsx +48 -6
- package/app/costs/CostsSkeleton.tsx +88 -0
- package/app/costs/PageClient.tsx +366 -0
- package/app/costs/loading.tsx +13 -0
- package/app/costs/page.tsx +5 -0
- package/app/globals.css +42 -0
- package/app/system/AgentCharts.tsx +138 -0
- package/app/system/AgentsSection.tsx +281 -0
- package/app/system/PageClient.tsx +7 -7
- package/app/system/RecentAlerts.tsx +72 -0
- package/app/system/SystemSkeleton.tsx +53 -1
- package/app/system/loading.tsx +5 -1
- package/daemon.js +646 -41
- package/docs/ARCHITECTURE.md +72 -31
- package/docs/DESIGN-SYSTEM.md +2 -2
- package/docs/FRONTEND-ARCHITECTURE.md +14 -3
- package/docs/REV4A.md +6 -5
- package/docs/dev/API-REFERENCE.md +208 -38
- package/docs/dev/DATABASE.md +176 -20
- package/docs/dev/GATEWAY.md +53 -9
- package/docs/rag/DATA-FRESHNESS.md +34 -7
- package/docs/rag/GLOSSARY.md +8 -5
- package/docs/rag/REV4A-OVERVIEW.md +10 -3
- package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -3
- package/instrumentation.ts +11 -0
- package/lib/agent-costs.ts +172 -0
- package/lib/container-metrics.ts +340 -0
- package/lib/cost-reconciliation.ts +78 -0
- package/lib/costs-db.ts +31 -0
- package/lib/docker-socket-path.js +133 -0
- package/lib/docker-socket.ts +10 -99
- package/lib/docker-stats.js +284 -0
- package/lib/metrics-db.ts +15 -6
- package/lib/model-pricing.ts +123 -3
- package/lib/price-schedule-sync.ts +133 -0
- package/lib/utils/format.ts +38 -0
- package/model-pricing.json +269 -121
- package/package.json +1 -1
- package/scripts/refresh-model-pricing.mjs +16 -4
- package/scripts/test-docker-stats.mjs +270 -0
- package/lib/billing.ts +0 -100
package/lib/model-pricing.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Model pricing data — static, from model-pricing.json in the project root
|
|
2
|
+
* Model pricing data — static, from model-pricing.json in the project root, in $ per
|
|
3
|
+
* 1M tokens. `npm run refresh:pricing` regenerates the OpenRouter entries; the direct
|
|
4
|
+
* vendors' entries are kept by hand from each vendor's own pricing page.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
+
* Besides the display, these prices are what every agent's OpenClaw computes its costs
|
|
7
|
+
* with: `buildRev4aProviderConfig()` writes `priceAt()` into each model of the synced
|
|
8
|
+
* `rev4a` block, and a vendor with time-of-day pricing (PRICE_SCHEDULES) is re-synced
|
|
9
|
+
* at each change of rate (lib/price-schedule-sync.ts).
|
|
6
10
|
*/
|
|
7
11
|
import * as fs from 'fs';
|
|
8
12
|
import * as path from 'path';
|
|
@@ -10,8 +14,48 @@ import * as path from 'path';
|
|
|
10
14
|
export interface ModelPricing {
|
|
11
15
|
input: number;
|
|
12
16
|
output: number;
|
|
17
|
+
/** Cached input read. Absent when the vendor's cache rate is not known. */
|
|
18
|
+
cacheRead?: number;
|
|
19
|
+
/** Cached input written. Absent when not known or not billed apart from input. */
|
|
20
|
+
cacheWrite?: number;
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
/** The four rates OpenClaw's `models.providers.*.models[].cost` takes, $ per 1M tokens. */
|
|
24
|
+
export interface ModelCost {
|
|
25
|
+
input: number;
|
|
26
|
+
output: number;
|
|
27
|
+
cacheRead: number;
|
|
28
|
+
cacheWrite: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A vendor whose rates depend on the time of day: `peak` windows at the listed rates,
|
|
33
|
+
* every other hour at `offPeakFactor` times them. Hours are UTC, `[from, to)`; days are
|
|
34
|
+
* UTC weekdays, 0 = Sunday.
|
|
35
|
+
*/
|
|
36
|
+
export interface PriceSchedule {
|
|
37
|
+
source: string;
|
|
38
|
+
peak: { days: number[]; hours: [number, number][] };
|
|
39
|
+
offPeakFactor: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Keyed by the first segment of our model id: `deepseek/deepseek-flash` is DeepSeek's
|
|
44
|
+
* own API. `openrouter/deepseek/…` is OpenRouter's price and has no schedule.
|
|
45
|
+
*
|
|
46
|
+
* DeepSeek (checked 2026-09-27): "Off-peak rates are half of the peak rates. Peak hours
|
|
47
|
+
* are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese
|
|
48
|
+
* public holidays." Those holidays are not modelled: a call on one is priced at peak, a
|
|
49
|
+
* small overestimate on a few days a year.
|
|
50
|
+
*/
|
|
51
|
+
export const PRICE_SCHEDULES: Record<string, PriceSchedule> = {
|
|
52
|
+
deepseek: {
|
|
53
|
+
source: 'https://api-docs.deepseek.com/quick_start/pricing',
|
|
54
|
+
peak: { days: [1, 2, 3, 4, 5], hours: [[1, 4], [6, 10]] },
|
|
55
|
+
offPeakFactor: 0.5,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
15
59
|
let _cache: Record<string, ModelPricing> | null = null;
|
|
16
60
|
let _cacheMtime = 0;
|
|
17
61
|
|
|
@@ -61,3 +105,79 @@ export function formatPricing(p: ModelPricing | null | undefined): string {
|
|
|
61
105
|
};
|
|
62
106
|
return `${fmt(p.input)} / ${fmt(p.output)}`;
|
|
63
107
|
}
|
|
108
|
+
|
|
109
|
+
/** The time-of-day schedule a model is billed on, or null for a flat price. */
|
|
110
|
+
export function priceScheduleFor(modelId: string): PriceSchedule | null {
|
|
111
|
+
return PRICE_SCHEDULES[modelId.split('/')[0]] ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isPeak(schedule: PriceSchedule, at: Date): boolean {
|
|
115
|
+
if (!schedule.peak.days.includes(at.getUTCDay())) return false;
|
|
116
|
+
const h = at.getUTCHours();
|
|
117
|
+
return schedule.peak.hours.some(([from, to]) => h >= from && h < to);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 'peak' or 'off-peak' for a model on a schedule, null for a flat price. */
|
|
121
|
+
export function priceBandAt(modelId: string, at: Date = new Date()): 'peak' | 'off-peak' | null {
|
|
122
|
+
const schedule = priceScheduleFor(modelId);
|
|
123
|
+
if (!schedule) return null;
|
|
124
|
+
return isPeak(schedule, at) ? 'peak' : 'off-peak';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const round6 = (n: number) => Math.round(n * 1e6) / 1e6;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* What a free model is written with. OpenClaw treats an all-zero cost as "no price" and
|
|
131
|
+
* counts every call as unpriced, which would flag free models as missing a price. One
|
|
132
|
+
* millionth of a dollar per 1M tokens makes them priced and, in practice, free: a billion
|
|
133
|
+
* tokens come to $0.001.
|
|
134
|
+
*/
|
|
135
|
+
const FREE_RATE = 0.000001;
|
|
136
|
+
/**
|
|
137
|
+
* A cache write with no published rate, as a multiple of the input rate. Vendors that
|
|
138
|
+
* bill cache writes apart charge more than input (Anthropic: 1.25× for its default
|
|
139
|
+
* five-minute cache); the ones that do not report cache writes at all never use it.
|
|
140
|
+
*/
|
|
141
|
+
const CACHE_WRITE_FALLBACK = 1.25;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The rates in force for a model at a moment, in the shape OpenClaw's config takes.
|
|
145
|
+
*
|
|
146
|
+
* Null when there is nothing to write: no entry, or a dynamic router price (-1), whose
|
|
147
|
+
* calls OpenClaw then counts as unpriced. A free model (all zero) gets FREE_RATE. A cache
|
|
148
|
+
* rate the file does not carry falls back to a rate that does not undercount: the input
|
|
149
|
+
* rate for a cache read (as if the cache gave no discount), CACHE_WRITE_FALLBACK × input
|
|
150
|
+
* for a cache write.
|
|
151
|
+
*/
|
|
152
|
+
export function priceAt(modelId: string, at: Date = new Date()): ModelCost | null {
|
|
153
|
+
const p = getPricing(modelId);
|
|
154
|
+
if (!p || p.input < 0 || p.output < 0) return null;
|
|
155
|
+
if (p.input === 0 && p.output === 0 && !p.cacheRead && !p.cacheWrite) {
|
|
156
|
+
return { input: FREE_RATE, output: FREE_RATE, cacheRead: FREE_RATE, cacheWrite: FREE_RATE };
|
|
157
|
+
}
|
|
158
|
+
const schedule = priceScheduleFor(modelId);
|
|
159
|
+
const factor = schedule && !isPeak(schedule, at) ? schedule.offPeakFactor : 1;
|
|
160
|
+
return {
|
|
161
|
+
input: round6(p.input * factor),
|
|
162
|
+
output: round6(p.output * factor),
|
|
163
|
+
cacheRead: round6((p.cacheRead ?? p.input) * factor),
|
|
164
|
+
cacheWrite: round6((p.cacheWrite ?? p.input * CACHE_WRITE_FALLBACK) * factor),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The next moment any of these models changes rate, or null when none is on a schedule.
|
|
170
|
+
* Every schedule changes on the hour, so the search walks hour marks (up to 8 days).
|
|
171
|
+
*/
|
|
172
|
+
export function nextPriceChange(modelIds: string[], from: Date = new Date()): Date | null {
|
|
173
|
+
const schedules = [...new Set(modelIds.map(priceScheduleFor).filter((s): s is PriceSchedule => s !== null))];
|
|
174
|
+
if (!schedules.length) return null;
|
|
175
|
+
const now = schedules.map((s) => isPeak(s, from));
|
|
176
|
+
const t = new Date(from);
|
|
177
|
+
t.setUTCMinutes(0, 0, 0);
|
|
178
|
+
for (let i = 0; i < 8 * 24; i++) {
|
|
179
|
+
t.setUTCHours(t.getUTCHours() + 1);
|
|
180
|
+
if (schedules.some((s, j) => isPeak(s, t) !== now[j])) return new Date(t);
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-sync the agents' prices when a vendor's rate changes with the time of day.
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw's `cost` has no notion of time: it prices each call with the rates in its
|
|
5
|
+
* config at that moment and keeps that cost. DeepSeek bills half price off-peak, so at
|
|
6
|
+
* every change of band the `rev4a` block is written again with the rates now in force
|
|
7
|
+
* (`priceAt()` in `buildRev4aProviderConfig()`). A config patch of `models` applies
|
|
8
|
+
* without a gateway restart.
|
|
9
|
+
*
|
|
10
|
+
* A check, not one long timer: at most every minute it compares the bands in force with
|
|
11
|
+
* the ones last written, and syncs only when they differ. A single timeout to the next
|
|
12
|
+
* change would fire late after the machine sleeps (a laptop), leaving every agent on
|
|
13
|
+
* the other band's rates; the check costs a little arithmetic and no I/O. When the next
|
|
14
|
+
* change is less than a minute away the check is set for that moment, so a change is
|
|
15
|
+
* applied within a couple of seconds.
|
|
16
|
+
*
|
|
17
|
+
* The check runs whether or not a scheduled model is on offer, and skips the sync when
|
|
18
|
+
* none is: enabling a DeepSeek model later needs no restart to be priced right. A sync
|
|
19
|
+
* that did not reach every agent is retried every few minutes, not every check — a full
|
|
20
|
+
* sync holds the server for a few seconds — and at most a few times: an agent busy with
|
|
21
|
+
* an update, recreate, restore or edit writes the prices itself when it finishes, and one
|
|
22
|
+
* that stays broken must not keep the server syncing every agent around the clock. A new
|
|
23
|
+
* change of band is applied at once, whatever retry was pending.
|
|
24
|
+
*/
|
|
25
|
+
import { syncAllAgents } from '@/app/api/gateway/sync';
|
|
26
|
+
import { loadModelsConfig, loadOfferedModels } from '@/lib/model-catalogue';
|
|
27
|
+
import { PRICE_SCHEDULES, nextPriceChange, priceBandAt, priceScheduleFor } from '@/lib/model-pricing';
|
|
28
|
+
|
|
29
|
+
const CHECK_MS = 60_000;
|
|
30
|
+
/** Past the hour mark, so the band has changed when the sync reads the clock. */
|
|
31
|
+
const AFTER_CHANGE_MS = 2_000;
|
|
32
|
+
const RETRY_MS = 5 * 60_000;
|
|
33
|
+
/** Syncs tried for one band before waiting for the next change (or the next save or restart). */
|
|
34
|
+
const MAX_TRIES = 3;
|
|
35
|
+
|
|
36
|
+
interface State {
|
|
37
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
38
|
+
/**
|
|
39
|
+
* The bands last written to the agents, e.g. `deepseek:off-peak`. Set by every sync,
|
|
40
|
+
* complete or not: a sync that misses one agent still writes every other one.
|
|
41
|
+
*/
|
|
42
|
+
applied?: string;
|
|
43
|
+
/** Syncs tried for `applied` that missed an agent. */
|
|
44
|
+
tries?: number;
|
|
45
|
+
/** When to retry for the agents the last sync missed; unset when nothing is owed. */
|
|
46
|
+
retryAfter?: number;
|
|
47
|
+
}
|
|
48
|
+
// One timer per process, across dev-mode module reloads.
|
|
49
|
+
const g = globalThis as unknown as { __rev4aPriceSchedule?: State };
|
|
50
|
+
const state: State = (g.__rev4aPriceSchedule ??= {});
|
|
51
|
+
|
|
52
|
+
/** The band of every scheduled vendor right now, as one comparable string. */
|
|
53
|
+
function bandsNow(at: Date): string {
|
|
54
|
+
return Object.keys(PRICE_SCHEDULES)
|
|
55
|
+
.map((vendor) => `${vendor}:${priceBandAt(`${vendor}/`, at)}`)
|
|
56
|
+
.join(',');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function schedule(): void {
|
|
60
|
+
if (state.timer) clearTimeout(state.timer);
|
|
61
|
+
const next = nextPriceChange(loadModelsConfig().map((m) => m.id));
|
|
62
|
+
const untilChange = next ? next.getTime() - Date.now() + AFTER_CHANGE_MS : Infinity;
|
|
63
|
+
state.timer = setTimeout(check, Math.max(1_000, Math.min(CHECK_MS, untilChange)));
|
|
64
|
+
state.timer.unref?.();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Write `bands` into every agent; on a miss, owe a retry until MAX_TRIES. */
|
|
68
|
+
function sync(bands: string): void {
|
|
69
|
+
let ok = false;
|
|
70
|
+
let summary = '';
|
|
71
|
+
try {
|
|
72
|
+
const outcome = syncAllAgents();
|
|
73
|
+
ok = outcome.ok;
|
|
74
|
+
summary = outcome.summary;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
summary = (e as Error).message;
|
|
77
|
+
}
|
|
78
|
+
state.applied = bands;
|
|
79
|
+
if (ok) {
|
|
80
|
+
state.tries = 0;
|
|
81
|
+
state.retryAfter = undefined;
|
|
82
|
+
console.log(`[rev4a] Price band now ${bands}: ${summary}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
state.tries = (state.tries ?? 0) + 1;
|
|
86
|
+
if (state.tries < MAX_TRIES) {
|
|
87
|
+
state.retryAfter = Date.now() + RETRY_MS;
|
|
88
|
+
console.warn(`[rev4a] Price band now ${bands}, sync incomplete: ${summary} Retrying in ${RETRY_MS / 60_000} min.`);
|
|
89
|
+
} else {
|
|
90
|
+
state.retryAfter = undefined;
|
|
91
|
+
console.warn(`[rev4a] Price band now ${bands}, sync incomplete after ${state.tries} tries: ${summary} Not retried until the next change; Sync All Agents applies it sooner.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function check(): void {
|
|
96
|
+
try {
|
|
97
|
+
const bands = bandsNow(new Date());
|
|
98
|
+
const offered = loadOfferedModels().some((m) => priceScheduleFor(m.id));
|
|
99
|
+
if (bands !== state.applied) {
|
|
100
|
+
// A change of band: write it now, whatever retry was owed for the previous one.
|
|
101
|
+
state.tries = 0;
|
|
102
|
+
state.retryAfter = undefined;
|
|
103
|
+
if (offered) sync(bands);
|
|
104
|
+
else state.applied = bands; // nothing on offer is priced by time of day
|
|
105
|
+
} else if (state.retryAfter && Date.now() >= state.retryAfter) {
|
|
106
|
+
if (offered) sync(bands);
|
|
107
|
+
else state.retryAfter = undefined;
|
|
108
|
+
}
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.warn(`[rev4a] Price band check failed: ${(e as Error).message}`);
|
|
111
|
+
} finally {
|
|
112
|
+
try {
|
|
113
|
+
schedule();
|
|
114
|
+
} catch (e) {
|
|
115
|
+
// Never let the chain die: check again in a minute.
|
|
116
|
+
console.warn(`[rev4a] Price band check could not be scheduled: ${(e as Error).message}`);
|
|
117
|
+
state.timer = setTimeout(check, CHECK_MS);
|
|
118
|
+
state.timer.unref?.();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Start the checks; called once at server start, after the startup sync. When that sync
|
|
125
|
+
* reached every agent, the bands it wrote count as applied; otherwise the first check
|
|
126
|
+
* (a minute later) writes them.
|
|
127
|
+
*/
|
|
128
|
+
export function startPriceScheduleSync(startupSynced: boolean): void {
|
|
129
|
+
state.applied = startupSynced ? bandsNow(new Date()) : undefined;
|
|
130
|
+
state.tries = 0;
|
|
131
|
+
state.retryAfter = undefined;
|
|
132
|
+
schedule();
|
|
133
|
+
}
|
package/lib/utils/format.ts
CHANGED
|
@@ -4,6 +4,44 @@ export function formatUsd(value: number | null | undefined): string {
|
|
|
4
4
|
return `$${Number(value ?? 0).toFixed(2)}`;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
/** Megabytes as MB, GB or TB with the precision a reader wants: '692 MB', '1.6 GB'. */
|
|
8
|
+
export function formatMb(mb: number | null | undefined): string {
|
|
9
|
+
if (mb === null || mb === undefined || !Number.isFinite(mb)) return '—';
|
|
10
|
+
if (mb >= 1024 * 1024) return `${(mb / 1024 / 1024).toFixed(2)} TB`;
|
|
11
|
+
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
|
|
12
|
+
return `${Math.round(mb)} MB`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A CPU share in percent, with the decimals a small figure needs (an idle agent is 0.02 % of Docker's cores). */
|
|
16
|
+
export function formatSharePercent(percent: number | null | undefined): string {
|
|
17
|
+
if (percent === null || percent === undefined || !Number.isFinite(percent)) return '—';
|
|
18
|
+
if (percent === 0) return '0%';
|
|
19
|
+
if (percent < 0.01) return '<0.01%';
|
|
20
|
+
if (percent < 1) return `${percent.toFixed(2)}%`;
|
|
21
|
+
if (percent < 10) return `${percent.toFixed(1)}%`;
|
|
22
|
+
return `${Math.round(percent)}%`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Bytes per second as B/s, KB/s or MB/s. */
|
|
26
|
+
export function formatRate(bytesPerSecond: number | null | undefined): string {
|
|
27
|
+
if (bytesPerSecond === null || bytesPerSecond === undefined || !Number.isFinite(bytesPerSecond)) return '—';
|
|
28
|
+
if (bytesPerSecond >= 1024 * 1024) return `${(bytesPerSecond / 1024 / 1024).toFixed(1)} MB/s`;
|
|
29
|
+
if (bytesPerSecond >= 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
|
|
30
|
+
return `${Math.round(bytesPerSecond)} B/s`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Dollars with the precision a small figure needs: an agent's day can be a fraction of a
|
|
35
|
+
* cent, which formatUsd would show as $0.00. Used wherever agent costs are shown.
|
|
36
|
+
*/
|
|
37
|
+
export function formatCost(value: number | null | undefined): string {
|
|
38
|
+
const v = Number(value ?? 0);
|
|
39
|
+
if (!v) return '$0.00';
|
|
40
|
+
if (v < 0.01) return `$${v.toFixed(4)}`;
|
|
41
|
+
if (v < 1) return `$${v.toFixed(3)}`;
|
|
42
|
+
return `$${v.toFixed(2)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
7
45
|
export function formatUsdOrDash(value: number | null | undefined): string {
|
|
8
46
|
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '-';
|
|
9
47
|
return formatUsd(value);
|