@animalabs/connectome-host 0.5.0 → 0.5.2
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/CHANGELOG.md +28 -0
- package/package.json +1 -1
- package/src/index.ts +17 -5
- package/src/logging-provider-wrapper.ts +165 -0
- package/src/modules/web-ui-module.ts +37 -1
- package/web/src/Settings.tsx +64 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.2 — 2026-07-26
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Settings preview reported unreachable budgets as fitting.** context-manager's
|
|
10
|
+
`PreviewResult.budgetTokens` is the *rejection* budget —
|
|
11
|
+
`(requested - reserve) * (1 + overBudgetGraceRatio)` — and its `fits` means
|
|
12
|
+
"would not throw `OverBudgetError`", not "fits the budget you asked for". On
|
|
13
|
+
Mythos (`overBudgetGraceRatio: 0.35`) those differ by a third: previewing
|
|
14
|
+
250k reported `fits: true` at 273,828 tokens, a budget the picker had in fact
|
|
15
|
+
exhausted trying to reach. The endpoint now returns an `accounting` block
|
|
16
|
+
separating `fitsRequested` / `withinGrace` / `unreachable`, and the panel
|
|
17
|
+
renders three distinct verdicts (fits / over-requested-but-graced /
|
|
18
|
+
would-hard-fail) plus the full budget derivation.
|
|
19
|
+
|
|
20
|
+
## 0.5.1
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **llm-calls logging for every provider**: `LoggingProviderAdapter`, a
|
|
25
|
+
provider-agnostic decorator over any `ProviderAdapter`, wraps the
|
|
26
|
+
openai-codex, openrouter, and openai-responses transports — which
|
|
27
|
+
previously had NO wire visibility (found post-deploy on Mica: zero
|
|
28
|
+
llm-calls files, requests undiagnosable). Full raw request + response
|
|
29
|
+
summary + usage + timing + error per call, size-guarded against
|
|
30
|
+
pathological payloads. Anthropic/Bedrock keep their purpose-built
|
|
31
|
+
logging classes.
|
|
32
|
+
|
|
5
33
|
## 0.5.0 — 2026-07-26
|
|
6
34
|
|
|
7
35
|
### Added
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
OpenRouterAdapter,
|
|
26
26
|
} from '@animalabs/membrane';
|
|
27
27
|
import { LoggingAnthropicAdapter } from './logging-adapter.js';
|
|
28
|
+
import { LoggingProviderAdapter } from './logging-provider-wrapper.js';
|
|
28
29
|
import { LoggingBedrockAdapter } from './logging-bedrock-adapter.js';
|
|
29
30
|
import { CodexSubscriptionAdapter } from './codex-subscription-adapter.js';
|
|
30
31
|
import { CallLedger } from './call-ledger.js';
|
|
@@ -835,11 +836,22 @@ async function main() {
|
|
|
835
836
|
? new LoggingBedrockAdapter({}, llmLogPath)
|
|
836
837
|
: undefined;
|
|
837
838
|
const adapter = provider === 'openai-responses'
|
|
838
|
-
? new
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
839
|
+
? new LoggingProviderAdapter(
|
|
840
|
+
new OpenAIResponsesAPIAdapter({
|
|
841
|
+
apiKey: config.openaiApiKey!,
|
|
842
|
+
baseURL: process.env.OPENAI_BASE_URL || undefined,
|
|
843
|
+
}),
|
|
844
|
+
llmLogPath,
|
|
845
|
+
)
|
|
846
|
+
// Codex/openrouter/openai adapters get the provider-agnostic logging
|
|
847
|
+
// decorator (llm-calls.jsonl); anthropic and bedrock keep their
|
|
848
|
+
// purpose-built logging classes below. The bare `codexAdapter` /
|
|
849
|
+
// `openrouterAdapter` instances stay un-wrapped for auth commands and
|
|
850
|
+
// dispose() — only the membrane sees the wrapper.
|
|
851
|
+
: bedrockAdapter
|
|
852
|
+
?? (openrouterAdapter ? new LoggingProviderAdapter(openrouterAdapter, llmLogPath) : undefined)
|
|
853
|
+
?? (codexAdapter ? new LoggingProviderAdapter(codexAdapter, llmLogPath) : undefined)
|
|
854
|
+
?? new LoggingAnthropicAdapter(
|
|
843
855
|
{
|
|
844
856
|
// Anthropic OAuth wins over API-key auth when both are present.
|
|
845
857
|
// Subscription tokens additionally require this beta header.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Provider-agnostic ProviderAdapter decorator that appends each LLM call's
|
|
2
|
+
// request, response, usage, timing, and any error to the same
|
|
3
|
+
// `llm-calls.<iso>.jsonl` file the Anthropic and Bedrock paths use.
|
|
4
|
+
//
|
|
5
|
+
// Exists because llm-call logging grew adapter-by-adapter (Anthropic subclass,
|
|
6
|
+
// Bedrock wrapper) and the remaining providers — openai-codex (Mica),
|
|
7
|
+
// openrouter (K3) — had NO wire visibility at all: a post-deploy "did her
|
|
8
|
+
// requests actually work?" check on Mica (2026-07-26) found zero llm-calls
|
|
9
|
+
// files because the codex path never logged. This decorator wraps ANY
|
|
10
|
+
// ProviderAdapter, so a new provider gets logging by construction rather than
|
|
11
|
+
// by remembering to build a logging twin.
|
|
12
|
+
//
|
|
13
|
+
// Contents: full raw request and a summarized-but-complete response record
|
|
14
|
+
// (stop reason, usage, per-block shape, full text) on every call, plus the
|
|
15
|
+
// raw provider response. Retention/rotation/S3 shipping is the host box's
|
|
16
|
+
// llm-logs sync job's problem — this file just writes lines.
|
|
17
|
+
//
|
|
18
|
+
// OOM note (learned on the Anthropic path, which once logged raw+normalized+
|
|
19
|
+
// summary and contributed to production OOMs): we serialize the record ONCE,
|
|
20
|
+
// and a size guard drops the raw bodies for pathological payloads (giant
|
|
21
|
+
// image batches) rather than buffering multi-hundred-MB lines.
|
|
22
|
+
//
|
|
23
|
+
// Decorator safety: adapters whose complete() internally delegates to their
|
|
24
|
+
// own stream() (codex does) self-call the INNER method, not the wrapper —
|
|
25
|
+
// each membrane-level call logs exactly once.
|
|
26
|
+
//
|
|
27
|
+
// Each line: { type: 'call'|'error', provider, kind: 'complete'|'stream',
|
|
28
|
+
// timestamp, durationMs, requestSummary, response?, rawRequest?,
|
|
29
|
+
// rawResponse?, error?, truncated? }
|
|
30
|
+
|
|
31
|
+
import type {
|
|
32
|
+
ProviderAdapter,
|
|
33
|
+
ProviderRequest,
|
|
34
|
+
ProviderResponse,
|
|
35
|
+
ProviderRequestOptions,
|
|
36
|
+
StreamCallbacks,
|
|
37
|
+
} from '@animalabs/membrane';
|
|
38
|
+
import { appendFileSync } from 'node:fs';
|
|
39
|
+
|
|
40
|
+
/** Above this many serialized bytes, drop raw bodies and keep summaries. */
|
|
41
|
+
const MAX_RECORD_BYTES = 16 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
function summarizeRequest(request: ProviderRequest): Record<string, unknown> {
|
|
44
|
+
const msgs = (request.messages ?? []) as Array<{ role?: string; content?: unknown }>;
|
|
45
|
+
const last = msgs[msgs.length - 1];
|
|
46
|
+
const lastPreview = typeof last?.content === 'string'
|
|
47
|
+
? last.content.slice(0, 200)
|
|
48
|
+
: Array.isArray(last?.content)
|
|
49
|
+
? (last.content as Array<{ type?: string; text?: string }>)
|
|
50
|
+
.map((b) => (b.type === 'text' ? (b.text ?? '').slice(0, 120) : `[${b.type}]`))
|
|
51
|
+
.join(' | ').slice(0, 300)
|
|
52
|
+
: undefined;
|
|
53
|
+
return {
|
|
54
|
+
model: request.model,
|
|
55
|
+
maxTokens: request.maxTokens,
|
|
56
|
+
messageCount: msgs.length,
|
|
57
|
+
systemChars: typeof request.system === 'string'
|
|
58
|
+
? request.system.length
|
|
59
|
+
: Array.isArray(request.system)
|
|
60
|
+
? JSON.stringify(request.system).length
|
|
61
|
+
: 0,
|
|
62
|
+
toolNames: (request.tools as Array<{ name?: string }> | undefined)?.map((t) => t.name) ?? null,
|
|
63
|
+
toolCount: (request.tools as unknown[] | undefined)?.length ?? 0,
|
|
64
|
+
lastMessageRole: last?.role,
|
|
65
|
+
lastMessagePreview: lastPreview,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function summarizeResponse(response: ProviderResponse): Record<string, unknown> {
|
|
70
|
+
const content = (response.content ?? []) as Array<{ type?: string; text?: string; name?: string }>;
|
|
71
|
+
return {
|
|
72
|
+
stopReason: response.stopReason
|
|
73
|
+
?? (response.raw as { stop_reason?: string } | undefined)?.stop_reason
|
|
74
|
+
?? null,
|
|
75
|
+
usage: response.usage ?? null,
|
|
76
|
+
blocks: content.map((b) => ({
|
|
77
|
+
type: b.type,
|
|
78
|
+
...(b.type === 'text' ? { chars: (b.text ?? '').length, text: b.text } : {}),
|
|
79
|
+
...(b.type === 'tool_use' ? { name: b.name } : {}),
|
|
80
|
+
})),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class LoggingProviderAdapter implements ProviderAdapter {
|
|
85
|
+
readonly name: string;
|
|
86
|
+
|
|
87
|
+
constructor(
|
|
88
|
+
private readonly inner: ProviderAdapter,
|
|
89
|
+
private readonly logPath: string,
|
|
90
|
+
) {
|
|
91
|
+
this.name = inner.name;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
supportsModel(modelId: string): boolean {
|
|
95
|
+
return this.inner.supportsModel(modelId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private log(record: Record<string, unknown>): void {
|
|
99
|
+
try {
|
|
100
|
+
let line = JSON.stringify(record);
|
|
101
|
+
if (line.length > MAX_RECORD_BYTES) {
|
|
102
|
+
const { rawRequest: _rq, rawResponse: _rr, ...rest } = record;
|
|
103
|
+
line = JSON.stringify({ ...rest, truncated: 'raw bodies dropped (record exceeded size guard)' });
|
|
104
|
+
}
|
|
105
|
+
appendFileSync(this.logPath, line + '\n');
|
|
106
|
+
} catch {
|
|
107
|
+
// Logging must never break inference.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private record(
|
|
112
|
+
kind: 'complete' | 'stream',
|
|
113
|
+
request: ProviderRequest,
|
|
114
|
+
started: number,
|
|
115
|
+
response?: ProviderResponse,
|
|
116
|
+
error?: unknown,
|
|
117
|
+
): void {
|
|
118
|
+
this.log({
|
|
119
|
+
type: error === undefined ? 'call' : 'error',
|
|
120
|
+
provider: this.name,
|
|
121
|
+
kind,
|
|
122
|
+
timestamp: new Date(started).toISOString(),
|
|
123
|
+
durationMs: Date.now() - started,
|
|
124
|
+
requestSummary: summarizeRequest(request),
|
|
125
|
+
rawRequest: request,
|
|
126
|
+
...(response !== undefined
|
|
127
|
+
? { response: summarizeResponse(response), rawResponse: response.raw ?? null }
|
|
128
|
+
: {}),
|
|
129
|
+
...(error !== undefined
|
|
130
|
+
? { error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) }
|
|
131
|
+
: {}),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async complete(
|
|
136
|
+
request: ProviderRequest,
|
|
137
|
+
options?: ProviderRequestOptions,
|
|
138
|
+
): Promise<ProviderResponse> {
|
|
139
|
+
const started = Date.now();
|
|
140
|
+
try {
|
|
141
|
+
const response = await this.inner.complete(request, options);
|
|
142
|
+
this.record('complete', request, started, response);
|
|
143
|
+
return response;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.record('complete', request, started, undefined, error);
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async stream(
|
|
151
|
+
request: ProviderRequest,
|
|
152
|
+
callbacks: StreamCallbacks,
|
|
153
|
+
options?: ProviderRequestOptions,
|
|
154
|
+
): Promise<ProviderResponse> {
|
|
155
|
+
const started = Date.now();
|
|
156
|
+
try {
|
|
157
|
+
const response = await this.inner.stream(request, callbacks, options);
|
|
158
|
+
this.record('stream', request, started, response);
|
|
159
|
+
return response;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.record('stream', request, started, undefined, error);
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -1249,7 +1249,43 @@ export class WebUiModule implements Module {
|
|
|
1249
1249
|
{ status: 501 },
|
|
1250
1250
|
);
|
|
1251
1251
|
}
|
|
1252
|
-
|
|
1252
|
+
// Honest budget accounting. context-manager's `budgetTokens` is the
|
|
1253
|
+
// REJECTION budget: (requested - reserve) * (1 + overBudgetGraceRatio),
|
|
1254
|
+
// i.e. the threshold above which a compile throws. Its `fits` therefore
|
|
1255
|
+
// means "would not hard-fail", NOT "fits the budget you asked for".
|
|
1256
|
+
//
|
|
1257
|
+
// On a recipe with overBudgetGraceRatio 0.35 those differ by a third, so
|
|
1258
|
+
// reporting cm's `fits` verbatim told operators that a budget they
|
|
1259
|
+
// cannot actually reach was fine. Split the two questions apart and let
|
|
1260
|
+
// the panel say which one it means.
|
|
1261
|
+
const r = result as { finalTokens?: number; budgetTokens?: number; exhausted?: boolean };
|
|
1262
|
+
const reserve = app.recipe.agent.maxTokens ?? 16_384;
|
|
1263
|
+
const effectiveBudget = Math.max(0, budget - reserve);
|
|
1264
|
+
const finalTokens = typeof r.finalTokens === 'number' ? r.finalTokens : NaN;
|
|
1265
|
+
const fitsRequested = Number.isFinite(finalTokens) && finalTokens <= effectiveBudget;
|
|
1266
|
+
const withinGrace = Number.isFinite(finalTokens) && typeof r.budgetTokens === 'number'
|
|
1267
|
+
? finalTokens <= r.budgetTokens
|
|
1268
|
+
: undefined;
|
|
1269
|
+
return Response.json({
|
|
1270
|
+
agent: agentName,
|
|
1271
|
+
budget,
|
|
1272
|
+
...(overrides as object),
|
|
1273
|
+
accounting: {
|
|
1274
|
+
requestedBudgetTokens: budget,
|
|
1275
|
+
reserveForResponseTokens: reserve,
|
|
1276
|
+
/** What the picker actually targets. */
|
|
1277
|
+
effectiveBudgetTokens: effectiveBudget,
|
|
1278
|
+
/** Hard-fail ceiling — requested minus reserve, plus grace. */
|
|
1279
|
+
rejectionBudgetTokens: r.budgetTokens,
|
|
1280
|
+
/** Fits the budget the operator asked for. */
|
|
1281
|
+
fitsRequested,
|
|
1282
|
+
/** Merely tolerated by the grace margin — over budget, but no throw. */
|
|
1283
|
+
withinGrace,
|
|
1284
|
+
/** Exhausted AND over the request => this budget is UNREACHABLE. */
|
|
1285
|
+
unreachable: r.exhausted === true && !fitsRequested,
|
|
1286
|
+
},
|
|
1287
|
+
preview: result,
|
|
1288
|
+
});
|
|
1253
1289
|
} catch (err) {
|
|
1254
1290
|
return Response.json(
|
|
1255
1291
|
{ error: err instanceof Error ? err.message : String(err) },
|
package/web/src/Settings.tsx
CHANGED
|
@@ -36,6 +36,24 @@ export interface SettingsState {
|
|
|
36
36
|
previewAvailable: boolean;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Honest budget accounting from the server.
|
|
41
|
+
*
|
|
42
|
+
* `preview.fits` from context-manager means "would not throw OverBudgetError",
|
|
43
|
+
* which on a recipe with a grace ratio is NOT the same as "fits the budget you
|
|
44
|
+
* typed". These three flags keep the questions separate.
|
|
45
|
+
*/
|
|
46
|
+
interface PreviewAccounting {
|
|
47
|
+
requestedBudgetTokens: number;
|
|
48
|
+
reserveForResponseTokens: number;
|
|
49
|
+
effectiveBudgetTokens: number;
|
|
50
|
+
rejectionBudgetTokens?: number;
|
|
51
|
+
fitsRequested: boolean;
|
|
52
|
+
withinGrace?: boolean;
|
|
53
|
+
/** Picker exhausted AND still over the request — this budget cannot be reached. */
|
|
54
|
+
unreachable: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
interface PreviewResult {
|
|
40
58
|
finalTokens: number;
|
|
41
59
|
budgetTokens: number;
|
|
@@ -91,6 +109,7 @@ export function SettingsPanel(props: {
|
|
|
91
109
|
const [notify, setNotify] = createSignal(false);
|
|
92
110
|
|
|
93
111
|
const [preview, setPreview] = createSignal<PreviewResult | null>(null);
|
|
112
|
+
const [acct, setAcct] = createSignal<PreviewAccounting | null>(null);
|
|
94
113
|
const [previewErr, setPreviewErr] = createSignal<string | null>(null);
|
|
95
114
|
const [previewing, setPreviewing] = createSignal(false);
|
|
96
115
|
|
|
@@ -133,12 +152,15 @@ export function SettingsPanel(props: {
|
|
|
133
152
|
const body = await res.json();
|
|
134
153
|
if (!res.ok) {
|
|
135
154
|
setPreview(null);
|
|
155
|
+
setAcct(null);
|
|
136
156
|
setPreviewErr(body?.error ?? `HTTP ${res.status}`);
|
|
137
157
|
return;
|
|
138
158
|
}
|
|
139
159
|
setPreview(body.preview as PreviewResult);
|
|
160
|
+
setAcct((body.accounting ?? null) as PreviewAccounting | null);
|
|
140
161
|
} catch (e) {
|
|
141
162
|
setPreview(null);
|
|
163
|
+
setAcct(null);
|
|
142
164
|
setPreviewErr(e instanceof Error ? e.message : String(e));
|
|
143
165
|
} finally {
|
|
144
166
|
setPreviewing(false);
|
|
@@ -357,20 +379,45 @@ export function SettingsPanel(props: {
|
|
|
357
379
|
<Show when={preview()}>
|
|
358
380
|
{(p) => (
|
|
359
381
|
<div>
|
|
382
|
+
{/* Three distinct verdicts. Collapsing "fits your budget" into
|
|
383
|
+
"wouldn't throw" is how a 35%-grace recipe reports an
|
|
384
|
+
unreachable budget as fine — so they are kept apart. */}
|
|
360
385
|
<div
|
|
361
386
|
class={`px-2 py-1 rounded mb-1.5 text-[11px] border ${
|
|
362
|
-
|
|
387
|
+
acct()?.fitsRequested
|
|
363
388
|
? 'bg-emerald-950/40 border-emerald-800 text-emerald-300'
|
|
364
|
-
:
|
|
389
|
+
: acct()?.withinGrace
|
|
390
|
+
? 'bg-amber-950/40 border-amber-800 text-amber-200'
|
|
391
|
+
: 'bg-red-950/40 border-red-800 text-red-300'
|
|
365
392
|
}`}
|
|
366
393
|
>
|
|
367
|
-
{
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
394
|
+
<Show when={acct()?.fitsRequested}>
|
|
395
|
+
<div>
|
|
396
|
+
fits — {p().finalTokens.toLocaleString()} of{' '}
|
|
397
|
+
{acct()!.effectiveBudgetTokens.toLocaleString()} usable tok
|
|
398
|
+
</div>
|
|
399
|
+
</Show>
|
|
400
|
+
<Show when={!acct()?.fitsRequested && acct()?.withinGrace}>
|
|
401
|
+
<div>
|
|
402
|
+
OVER REQUESTED BUDGET — {p().finalTokens.toLocaleString()} tok vs{' '}
|
|
403
|
+
{acct()!.effectiveBudgetTokens.toLocaleString()} usable
|
|
404
|
+
</div>
|
|
405
|
+
<div class="text-[10px] opacity-90 mt-0.5">
|
|
406
|
+
it would not hard-fail, but only because the grace margin absorbs the
|
|
407
|
+
overshoot (ceiling {acct()!.rejectionBudgetTokens?.toLocaleString()}).
|
|
408
|
+
The context would run above your target indefinitely.
|
|
409
|
+
</div>
|
|
410
|
+
</Show>
|
|
411
|
+
<Show when={acct() && !acct()!.fitsRequested && acct()!.withinGrace === false}>
|
|
412
|
+
<div>
|
|
413
|
+
WOULD HARD-FAIL — {p().finalTokens.toLocaleString()} tok exceeds even the
|
|
414
|
+
grace ceiling {acct()!.rejectionBudgetTokens?.toLocaleString()}
|
|
415
|
+
</div>
|
|
416
|
+
</Show>
|
|
417
|
+
<Show when={acct()?.unreachable}>
|
|
371
418
|
<div class="text-[10px] opacity-90 mt-0.5">
|
|
372
|
-
picker exhausted
|
|
373
|
-
|
|
419
|
+
<b>unreachable:</b> the picker exhausted — nothing further can be folded,
|
|
420
|
+
so this budget cannot be reached no matter how long you wait.
|
|
374
421
|
</div>
|
|
375
422
|
</Show>
|
|
376
423
|
</div>
|
|
@@ -378,6 +425,10 @@ export function SettingsPanel(props: {
|
|
|
378
425
|
<table class="w-full font-mono text-[10px]">
|
|
379
426
|
<tbody>
|
|
380
427
|
<For each={[
|
|
428
|
+
['requested budget', acct()?.requestedBudgetTokens],
|
|
429
|
+
['− reserve for response', acct()?.reserveForResponseTokens],
|
|
430
|
+
['= usable for context', acct()?.effectiveBudgetTokens],
|
|
431
|
+
['grace ceiling (hard fail above)', acct()?.rejectionBudgetTokens],
|
|
381
432
|
['head (verbatim)', p().headTokens],
|
|
382
433
|
['tail (verbatim)', p().tailTokens],
|
|
383
434
|
['middle (foldable)', p().middleTokens],
|
|
@@ -389,7 +440,11 @@ export function SettingsPanel(props: {
|
|
|
389
440
|
<tr>
|
|
390
441
|
<td class="text-neutral-500 pr-2">{k}</td>
|
|
391
442
|
<td class="text-neutral-300 text-right tabular-nums">
|
|
392
|
-
{typeof v === 'number'
|
|
443
|
+
{typeof v === 'number'
|
|
444
|
+
? v.toLocaleString()
|
|
445
|
+
: v === undefined || v === null
|
|
446
|
+
? '–'
|
|
447
|
+
: String(v)}
|
|
393
448
|
</td>
|
|
394
449
|
</tr>
|
|
395
450
|
)}
|