@hasna/economy 0.3.10 → 0.3.15
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 +52 -2
- package/dist/cli/index.js +267 -560
- package/dist/db/cloud.d.ts +1 -3
- package/dist/db/cloud.d.ts.map +1 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/index.js +2 -429
- package/dist/lib/cloud-ingest.d.ts +57 -0
- package/dist/lib/cloud-ingest.d.ts.map +1 -0
- package/dist/lib/cloud-storage.d.ts +5 -5
- package/dist/lib/cloud-storage.d.ts.map +1 -1
- package/dist/lib/contracts-client/index.d.ts.map +1 -1
- package/dist/lib/contracts-client/storage.d.ts +2 -82
- package/dist/lib/contracts-client/storage.d.ts.map +1 -1
- package/dist/lib/contracts-client/transport.d.ts +1 -147
- package/dist/lib/contracts-client/transport.d.ts.map +1 -1
- package/dist/lib/open-projects.d.ts.map +1 -1
- package/dist/lib/sync-maintenance.d.ts +15 -0
- package/dist/lib/sync-maintenance.d.ts.map +1 -0
- package/dist/mcp/index.js +194 -471
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/server/index.js +42 -13
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/docs/cli.md +3 -3
- package/docs/mcp.md +1 -1
- package/package.json +6 -6
- package/dist/lib/contracts-client/mode.d.ts +0 -20
- package/dist/lib/contracts-client/mode.d.ts.map +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -591,68 +591,6 @@ var init_pricing = __esm(() => {
|
|
|
591
591
|
});
|
|
592
592
|
|
|
593
593
|
// src/db/database.ts
|
|
594
|
-
var exports_database = {};
|
|
595
|
-
__export(exports_database, {
|
|
596
|
-
upsertUsageSnapshot: () => upsertUsageSnapshot,
|
|
597
|
-
upsertSubscription: () => upsertSubscription,
|
|
598
|
-
upsertSession: () => upsertSession,
|
|
599
|
-
upsertRequest: () => upsertRequest,
|
|
600
|
-
upsertProject: () => upsertProject,
|
|
601
|
-
upsertModelPricing: () => upsertModelPricing,
|
|
602
|
-
upsertGoal: () => upsertGoal,
|
|
603
|
-
upsertCostCenter: () => upsertCostCenter,
|
|
604
|
-
upsertBudget: () => upsertBudget,
|
|
605
|
-
upsertBillingDaily: () => upsertBillingDaily,
|
|
606
|
-
setIngestState: () => setIngestState,
|
|
607
|
-
seedModelPricing: () => seedModelPricing,
|
|
608
|
-
rollupSession: () => rollupSession,
|
|
609
|
-
queryZeroCostTokenizedModels: () => queryZeroCostTokenizedModels,
|
|
610
|
-
queryUsageSnapshots: () => queryUsageSnapshots,
|
|
611
|
-
queryTopSessions: () => queryTopSessions,
|
|
612
|
-
querySummary: () => querySummary,
|
|
613
|
-
querySessions: () => querySessions,
|
|
614
|
-
queryRequestsSince: () => queryRequestsSince,
|
|
615
|
-
queryProjectBreakdownSince: () => queryProjectBreakdownSince,
|
|
616
|
-
queryProjectBreakdown: () => queryProjectBreakdown,
|
|
617
|
-
queryModelBreakdownSince: () => queryModelBreakdownSince,
|
|
618
|
-
queryModelBreakdown: () => queryModelBreakdown,
|
|
619
|
-
queryHourlyBreakdown: () => queryHourlyBreakdown,
|
|
620
|
-
queryDailyBreakdown: () => queryDailyBreakdown,
|
|
621
|
-
queryCostCenterBreakdown: () => queryCostCenterBreakdown,
|
|
622
|
-
queryBillingSummary: () => queryBillingSummary,
|
|
623
|
-
queryAgentBreakdownSince: () => queryAgentBreakdownSince,
|
|
624
|
-
queryAgentBreakdown: () => queryAgentBreakdown,
|
|
625
|
-
queryAccountBreakdownSince: () => queryAccountBreakdownSince,
|
|
626
|
-
queryAccountBreakdown: () => queryAccountBreakdown,
|
|
627
|
-
openDatabase: () => openDatabase,
|
|
628
|
-
listSubscriptions: () => listSubscriptions,
|
|
629
|
-
listProjects: () => listProjects,
|
|
630
|
-
listModelPricing: () => listModelPricing,
|
|
631
|
-
listMachines: () => listMachines,
|
|
632
|
-
listMachineRegistry: () => listMachineRegistry,
|
|
633
|
-
listGoals: () => listGoals,
|
|
634
|
-
listBudgets: () => listBudgets,
|
|
635
|
-
insertFeedback: () => insertFeedback,
|
|
636
|
-
getSessionDetail: () => getSessionDetail,
|
|
637
|
-
getProject: () => getProject,
|
|
638
|
-
getModelPricing: () => getModelPricing,
|
|
639
|
-
getMachineId: () => getMachineId,
|
|
640
|
-
getIngestState: () => getIngestState,
|
|
641
|
-
getGoalStatuses: () => getGoalStatuses,
|
|
642
|
-
getDbPath: () => getDbPath,
|
|
643
|
-
getDataDir: () => getDataDir,
|
|
644
|
-
getCostCenter: () => getCostCenter,
|
|
645
|
-
getBudgetStatuses: () => getBudgetStatuses,
|
|
646
|
-
deleteSubscription: () => deleteSubscription,
|
|
647
|
-
deleteProject: () => deleteProject,
|
|
648
|
-
deleteModelPricing: () => deleteModelPricing,
|
|
649
|
-
deleteGoal: () => deleteGoal,
|
|
650
|
-
deleteBudget: () => deleteBudget,
|
|
651
|
-
dedupeRequests: () => dedupeRequests,
|
|
652
|
-
countBillingRecords: () => countBillingRecords,
|
|
653
|
-
clearBillingRange: () => clearBillingRange,
|
|
654
|
-
bulkIngest: () => bulkIngest
|
|
655
|
-
});
|
|
656
594
|
import { execFileSync } from "child_process";
|
|
657
595
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
658
596
|
import { hostname, platform } from "os";
|
|
@@ -1066,9 +1004,6 @@ function upsertCostCenter(db, center) {
|
|
|
1066
1004
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1067
1005
|
`).run(center.id, center.kind, center.name, center.repo_path ?? null, center.labels_json ?? "{}", center.created_at, updatedAt);
|
|
1068
1006
|
}
|
|
1069
|
-
function getCostCenter(db, id) {
|
|
1070
|
-
return db.prepare(`SELECT * FROM cost_centers WHERE id = ?`).get(id);
|
|
1071
|
-
}
|
|
1072
1007
|
function upsertRequest(db, req) {
|
|
1073
1008
|
const now = req.updated_at ?? new Date().toISOString();
|
|
1074
1009
|
db.prepare(`
|
|
@@ -2190,6 +2125,7 @@ function ingestSpecs() {
|
|
|
2190
2125
|
"timestamp",
|
|
2191
2126
|
"source_request_id",
|
|
2192
2127
|
"machine_id",
|
|
2128
|
+
"cost_center_id",
|
|
2193
2129
|
"attribution_tag",
|
|
2194
2130
|
"account_key",
|
|
2195
2131
|
"account_tool",
|
|
@@ -2216,6 +2152,7 @@ function ingestSpecs() {
|
|
|
2216
2152
|
strOr(r["timestamp"]),
|
|
2217
2153
|
r["source_request_id"] == null ? null : strOr(r["source_request_id"]),
|
|
2218
2154
|
strOr(r["machine_id"]),
|
|
2155
|
+
r["cost_center_id"] == null ? null : strOr(r["cost_center_id"]),
|
|
2219
2156
|
strOr(r["attribution_tag"]),
|
|
2220
2157
|
strOr(r["account_key"]),
|
|
2221
2158
|
strOr(r["account_tool"]),
|
|
@@ -2240,6 +2177,7 @@ function ingestSpecs() {
|
|
|
2240
2177
|
"total_tokens",
|
|
2241
2178
|
"request_count",
|
|
2242
2179
|
"machine_id",
|
|
2180
|
+
"cost_center_id",
|
|
2243
2181
|
"attribution_tag",
|
|
2244
2182
|
"account_key",
|
|
2245
2183
|
"account_tool",
|
|
@@ -2260,6 +2198,7 @@ function ingestSpecs() {
|
|
|
2260
2198
|
numOr(r["total_tokens"]),
|
|
2261
2199
|
numOr(r["request_count"]),
|
|
2262
2200
|
strOr(r["machine_id"]),
|
|
2201
|
+
r["cost_center_id"] == null ? null : strOr(r["cost_center_id"]),
|
|
2263
2202
|
strOr(r["attribution_tag"]),
|
|
2264
2203
|
strOr(r["account_key"]),
|
|
2265
2204
|
strOr(r["account_tool"]),
|
|
@@ -3441,448 +3380,14 @@ function queryModelEfficiency(db) {
|
|
|
3441
3380
|
FROM requests GROUP BY model ORDER BY cost DESC`).all();
|
|
3442
3381
|
}
|
|
3443
3382
|
|
|
3444
|
-
// src/lib/contracts-client/mode.ts
|
|
3445
|
-
function normalizeStorageMode(value) {
|
|
3446
|
-
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
3447
|
-
if (normalized === "local")
|
|
3448
|
-
return { mode: "local", deprecatedAlias: null };
|
|
3449
|
-
if (normalized === "cloud")
|
|
3450
|
-
return { mode: "cloud", deprecatedAlias: null };
|
|
3451
|
-
if (DEPRECATED_STORAGE_MODE_ALIASES.includes(normalized)) {
|
|
3452
|
-
return { mode: "cloud", deprecatedAlias: normalized };
|
|
3453
|
-
}
|
|
3454
|
-
throw new Error(`Unknown storage mode: ${value}. Use local or cloud.`);
|
|
3455
|
-
}
|
|
3456
|
-
function envToken(name) {
|
|
3457
|
-
return name.toUpperCase().replace(/-/g, "_");
|
|
3458
|
-
}
|
|
3459
|
-
var DEPRECATED_STORAGE_MODE_ALIASES;
|
|
3460
|
-
var init_mode = __esm(() => {
|
|
3461
|
-
DEPRECATED_STORAGE_MODE_ALIASES = ["remote", "hybrid", "self_hosted"];
|
|
3462
|
-
});
|
|
3463
|
-
|
|
3464
|
-
// src/lib/contracts-client/transport.ts
|
|
3465
|
-
function defaultCloudBaseUrl(name) {
|
|
3466
|
-
return `https://${name}.hasna.xyz`;
|
|
3467
|
-
}
|
|
3468
|
-
function clientTransportEnvKeys(name) {
|
|
3469
|
-
const token = envToken(name);
|
|
3470
|
-
return {
|
|
3471
|
-
modeKeys: [
|
|
3472
|
-
`HASNA_${token}_STORAGE_MODE`,
|
|
3473
|
-
`HASNA_${token}_MODE`,
|
|
3474
|
-
`${token}_STORAGE_MODE`,
|
|
3475
|
-
`${token}_MODE`
|
|
3476
|
-
],
|
|
3477
|
-
apiUrlKeys: [`HASNA_${token}_API_URL`, `${token}_API_URL`],
|
|
3478
|
-
apiKeyKeys: [`HASNA_${token}_API_KEY`, `${token}_API_KEY`]
|
|
3479
|
-
};
|
|
3480
|
-
}
|
|
3481
|
-
function firstEnv(env, keys) {
|
|
3482
|
-
for (const key of keys) {
|
|
3483
|
-
const value = env[key]?.trim();
|
|
3484
|
-
if (value)
|
|
3485
|
-
return { key, value };
|
|
3486
|
-
}
|
|
3487
|
-
return null;
|
|
3488
|
-
}
|
|
3489
|
-
function toV1BaseUrl(apiUrl) {
|
|
3490
|
-
const url = new URL(apiUrl);
|
|
3491
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
3492
|
-
throw new Error("API URL must use http or https.");
|
|
3493
|
-
}
|
|
3494
|
-
let path = url.pathname.replace(/\/+$/, "");
|
|
3495
|
-
if (path.endsWith("/v1"))
|
|
3496
|
-
path = path.slice(0, -"/v1".length);
|
|
3497
|
-
url.pathname = `${path}/v1`;
|
|
3498
|
-
url.search = "";
|
|
3499
|
-
url.hash = "";
|
|
3500
|
-
return url.toString().replace(/\/+$/, "");
|
|
3501
|
-
}
|
|
3502
|
-
function resolveClientTransport(name, env = process.env) {
|
|
3503
|
-
const keys = clientTransportEnvKeys(name);
|
|
3504
|
-
const modeHit = firstEnv(env, keys.modeKeys);
|
|
3505
|
-
const urlHit = firstEnv(env, keys.apiUrlKeys);
|
|
3506
|
-
const keyHit = firstEnv(env, keys.apiKeyKeys);
|
|
3507
|
-
let mode = "local";
|
|
3508
|
-
let deprecatedAlias = null;
|
|
3509
|
-
let modeSource = "default";
|
|
3510
|
-
const warnings = [];
|
|
3511
|
-
if (modeHit) {
|
|
3512
|
-
const normalized = normalizeStorageMode(modeHit.value);
|
|
3513
|
-
mode = normalized.mode;
|
|
3514
|
-
deprecatedAlias = normalized.deprecatedAlias;
|
|
3515
|
-
modeSource = modeHit.key;
|
|
3516
|
-
if (deprecatedAlias) {
|
|
3517
|
-
warnings.push(`Deprecated mode '${deprecatedAlias}' from ${modeHit.key} is treated as 'cloud'. Prefer ${keys.modeKeys[0]}=cloud.`);
|
|
3518
|
-
}
|
|
3519
|
-
} else if (urlHit && keyHit) {
|
|
3520
|
-
mode = "cloud";
|
|
3521
|
-
modeSource = `${urlHit.key}+${keyHit.key}`;
|
|
3522
|
-
} else if (urlHit) {
|
|
3523
|
-
warnings.push(`${urlHit.key} is set but no API key is present (${keys.apiKeyKeys[0]}). ` + `Refusing to fall back to the local store, which would silently serve a different dataset. ` + `Set ${keys.apiKeyKeys[0]} to enable the cloud client, or set ${keys.modeKeys[0]}=local ` + `to use the local store deliberately.`);
|
|
3524
|
-
return {
|
|
3525
|
-
transport: "local",
|
|
3526
|
-
mode: "local",
|
|
3527
|
-
deprecatedAlias,
|
|
3528
|
-
modeSource: urlHit.key,
|
|
3529
|
-
baseUrl: null,
|
|
3530
|
-
apiUrlSource: null,
|
|
3531
|
-
apiKeyPresent: false,
|
|
3532
|
-
apiKeySource: null,
|
|
3533
|
-
misconfigured: true,
|
|
3534
|
-
warning: warnings.join(" ")
|
|
3535
|
-
};
|
|
3536
|
-
}
|
|
3537
|
-
if (mode === "local") {
|
|
3538
|
-
return {
|
|
3539
|
-
transport: "local",
|
|
3540
|
-
mode,
|
|
3541
|
-
deprecatedAlias,
|
|
3542
|
-
modeSource,
|
|
3543
|
-
baseUrl: null,
|
|
3544
|
-
apiUrlSource: null,
|
|
3545
|
-
apiKeyPresent: Boolean(keyHit),
|
|
3546
|
-
apiKeySource: keyHit ? keyHit.key : null,
|
|
3547
|
-
misconfigured: false,
|
|
3548
|
-
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
3549
|
-
};
|
|
3550
|
-
}
|
|
3551
|
-
if (!keyHit) {
|
|
3552
|
-
warnings.push(`${modeSource}=cloud but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to cloud; using local store. Set ${keys.apiKeyKeys[0]} to enable the cloud client.`);
|
|
3553
|
-
return {
|
|
3554
|
-
transport: "local",
|
|
3555
|
-
mode,
|
|
3556
|
-
deprecatedAlias,
|
|
3557
|
-
modeSource,
|
|
3558
|
-
baseUrl: null,
|
|
3559
|
-
apiUrlSource: null,
|
|
3560
|
-
apiKeyPresent: false,
|
|
3561
|
-
apiKeySource: null,
|
|
3562
|
-
misconfigured: true,
|
|
3563
|
-
warning: warnings.join(" ")
|
|
3564
|
-
};
|
|
3565
|
-
}
|
|
3566
|
-
const rawUrl = urlHit?.value ?? defaultCloudBaseUrl(name);
|
|
3567
|
-
const apiUrlSource = urlHit ? urlHit.key : "default";
|
|
3568
|
-
let baseUrl;
|
|
3569
|
-
try {
|
|
3570
|
-
baseUrl = toV1BaseUrl(rawUrl);
|
|
3571
|
-
} catch (error) {
|
|
3572
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
3573
|
-
warnings.push(`Invalid API URL from ${apiUrlSource}: ${message}. Using local store.`);
|
|
3574
|
-
return {
|
|
3575
|
-
transport: "local",
|
|
3576
|
-
mode,
|
|
3577
|
-
deprecatedAlias,
|
|
3578
|
-
modeSource,
|
|
3579
|
-
baseUrl: null,
|
|
3580
|
-
apiUrlSource: null,
|
|
3581
|
-
apiKeyPresent: true,
|
|
3582
|
-
apiKeySource: keyHit.key,
|
|
3583
|
-
misconfigured: true,
|
|
3584
|
-
warning: warnings.join(" ")
|
|
3585
|
-
};
|
|
3586
|
-
}
|
|
3587
|
-
return {
|
|
3588
|
-
transport: "cloud-http",
|
|
3589
|
-
mode,
|
|
3590
|
-
deprecatedAlias,
|
|
3591
|
-
modeSource,
|
|
3592
|
-
baseUrl,
|
|
3593
|
-
apiUrlSource,
|
|
3594
|
-
apiKeyPresent: true,
|
|
3595
|
-
apiKeySource: keyHit.key,
|
|
3596
|
-
misconfigured: false,
|
|
3597
|
-
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
3598
|
-
};
|
|
3599
|
-
}
|
|
3600
|
-
function appendQuery(path, query) {
|
|
3601
|
-
if (!query)
|
|
3602
|
-
return path;
|
|
3603
|
-
const params = query instanceof URLSearchParams ? query : new URLSearchParams;
|
|
3604
|
-
if (!(query instanceof URLSearchParams)) {
|
|
3605
|
-
for (const [key, value] of Object.entries(query)) {
|
|
3606
|
-
if (value === null || value === undefined)
|
|
3607
|
-
continue;
|
|
3608
|
-
if (Array.isArray(value)) {
|
|
3609
|
-
for (const v of value)
|
|
3610
|
-
params.append(key, String(v));
|
|
3611
|
-
} else {
|
|
3612
|
-
params.append(key, String(value));
|
|
3613
|
-
}
|
|
3614
|
-
}
|
|
3615
|
-
}
|
|
3616
|
-
const qs = params.toString();
|
|
3617
|
-
if (!qs)
|
|
3618
|
-
return path;
|
|
3619
|
-
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
3620
|
-
}
|
|
3621
|
-
function createHasnaHttpTransport(options) {
|
|
3622
|
-
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
3623
|
-
const base = options.baseUrl.replace(/\/+$/, "");
|
|
3624
|
-
const timeoutMs = options.timeoutMs ?? 30000;
|
|
3625
|
-
const sleep = options.sleepImpl ?? defaultSleep;
|
|
3626
|
-
const defaultRetry = options.retry;
|
|
3627
|
-
function resolveRetry(callRetry) {
|
|
3628
|
-
const chosen = callRetry !== undefined ? callRetry : defaultRetry;
|
|
3629
|
-
if (chosen === false)
|
|
3630
|
-
return null;
|
|
3631
|
-
const r = chosen ?? {};
|
|
3632
|
-
return {
|
|
3633
|
-
retries: r.retries ?? 2,
|
|
3634
|
-
baseDelayMs: r.baseDelayMs ?? 200,
|
|
3635
|
-
maxDelayMs: r.maxDelayMs ?? 2000,
|
|
3636
|
-
retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES]
|
|
3637
|
-
};
|
|
3638
|
-
}
|
|
3639
|
-
async function once(method, rel, url, body, opts) {
|
|
3640
|
-
const headers = {
|
|
3641
|
-
"x-api-key": options.apiKey,
|
|
3642
|
-
Authorization: `Bearer ${options.apiKey}`,
|
|
3643
|
-
Accept: "application/json",
|
|
3644
|
-
...options.headers ?? {},
|
|
3645
|
-
...opts.headers ?? {}
|
|
3646
|
-
};
|
|
3647
|
-
if (opts.idempotencyKey)
|
|
3648
|
-
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
3649
|
-
const init = { method, headers };
|
|
3650
|
-
if (body !== undefined) {
|
|
3651
|
-
headers["Content-Type"] = "application/json";
|
|
3652
|
-
init.body = JSON.stringify(body);
|
|
3653
|
-
}
|
|
3654
|
-
const controller = new AbortController;
|
|
3655
|
-
const onAbort = () => controller.abort();
|
|
3656
|
-
if (opts.signal) {
|
|
3657
|
-
if (opts.signal.aborted)
|
|
3658
|
-
controller.abort();
|
|
3659
|
-
else
|
|
3660
|
-
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
3661
|
-
}
|
|
3662
|
-
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs);
|
|
3663
|
-
init.signal = controller.signal;
|
|
3664
|
-
let response;
|
|
3665
|
-
try {
|
|
3666
|
-
response = await fetchImpl(url, init);
|
|
3667
|
-
} catch (error) {
|
|
3668
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
3669
|
-
if (opts.signal?.aborted)
|
|
3670
|
-
return { ok: false, retryable: false, error: err };
|
|
3671
|
-
return { ok: false, retryable: true, error: err };
|
|
3672
|
-
} finally {
|
|
3673
|
-
clearTimeout(timer);
|
|
3674
|
-
if (opts.signal)
|
|
3675
|
-
opts.signal.removeEventListener("abort", onAbort);
|
|
3676
|
-
}
|
|
3677
|
-
const text = await response.text();
|
|
3678
|
-
let parsed = undefined;
|
|
3679
|
-
if (text.length > 0) {
|
|
3680
|
-
try {
|
|
3681
|
-
parsed = JSON.parse(text);
|
|
3682
|
-
} catch {
|
|
3683
|
-
parsed = text;
|
|
3684
|
-
}
|
|
3685
|
-
}
|
|
3686
|
-
if (!response.ok) {
|
|
3687
|
-
const retry = resolveRetry(opts.retry);
|
|
3688
|
-
const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
|
|
3689
|
-
return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) };
|
|
3690
|
-
}
|
|
3691
|
-
return { ok: true, value: parsed };
|
|
3692
|
-
}
|
|
3693
|
-
async function request(method, path, body, opts = {}) {
|
|
3694
|
-
const upper = method.toUpperCase();
|
|
3695
|
-
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
3696
|
-
const url = `${base}${rel}`;
|
|
3697
|
-
const retry = resolveRetry(opts.retry);
|
|
3698
|
-
const methodRetryable = IDEMPOTENT_METHODS.has(upper) || Boolean(opts.idempotencyKey);
|
|
3699
|
-
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
3700
|
-
let last = null;
|
|
3701
|
-
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
3702
|
-
const result = await once(upper, rel, url, body, opts);
|
|
3703
|
-
if (result.ok)
|
|
3704
|
-
return result.value;
|
|
3705
|
-
last = result;
|
|
3706
|
-
const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
|
|
3707
|
-
if (!canRetry)
|
|
3708
|
-
break;
|
|
3709
|
-
const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
|
|
3710
|
-
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
3711
|
-
await sleep(backoff + jitter);
|
|
3712
|
-
}
|
|
3713
|
-
throw last.error;
|
|
3714
|
-
}
|
|
3715
|
-
return {
|
|
3716
|
-
baseUrl: base,
|
|
3717
|
-
request,
|
|
3718
|
-
get: (path, opts) => request("GET", path, undefined, opts),
|
|
3719
|
-
post: (path, body, opts) => request("POST", path, body, opts),
|
|
3720
|
-
put: (path, body, opts) => request("PUT", path, body, opts),
|
|
3721
|
-
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
3722
|
-
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
3723
|
-
};
|
|
3724
|
-
}
|
|
3725
|
-
function createClientTransport(name, env = process.env, overrides) {
|
|
3726
|
-
const resolution = resolveClientTransport(name, env);
|
|
3727
|
-
if (resolution.misconfigured) {
|
|
3728
|
-
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for cloud mode.`);
|
|
3729
|
-
}
|
|
3730
|
-
if (resolution.transport === "local" || !resolution.baseUrl) {
|
|
3731
|
-
return { transport: "local", client: null, resolution };
|
|
3732
|
-
}
|
|
3733
|
-
const keys = clientTransportEnvKeys(name);
|
|
3734
|
-
const apiKey = firstEnv(env, keys.apiKeyKeys)?.value;
|
|
3735
|
-
if (!apiKey) {
|
|
3736
|
-
throw new Error(`Client for '${name}' resolved to cloud-http without an API key.`);
|
|
3737
|
-
}
|
|
3738
|
-
return {
|
|
3739
|
-
transport: "cloud-http",
|
|
3740
|
-
client: createHasnaHttpTransport({
|
|
3741
|
-
name,
|
|
3742
|
-
baseUrl: resolution.baseUrl,
|
|
3743
|
-
apiKey,
|
|
3744
|
-
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
3745
|
-
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
3746
|
-
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
3747
|
-
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
3748
|
-
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
3749
|
-
}),
|
|
3750
|
-
resolution
|
|
3751
|
-
};
|
|
3752
|
-
}
|
|
3753
|
-
var HasnaHttpError, DEFAULT_RETRY_STATUSES, IDEMPOTENT_METHODS, defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3754
|
-
var init_transport = __esm(() => {
|
|
3755
|
-
init_mode();
|
|
3756
|
-
HasnaHttpError = class HasnaHttpError extends Error {
|
|
3757
|
-
status;
|
|
3758
|
-
method;
|
|
3759
|
-
path;
|
|
3760
|
-
body;
|
|
3761
|
-
constructor(method, path, status, body) {
|
|
3762
|
-
super(`Hasna cloud request failed: ${method} ${path} -> ${status}`);
|
|
3763
|
-
this.name = "HasnaHttpError";
|
|
3764
|
-
this.status = status;
|
|
3765
|
-
this.method = method;
|
|
3766
|
-
this.path = path;
|
|
3767
|
-
this.body = body;
|
|
3768
|
-
}
|
|
3769
|
-
};
|
|
3770
|
-
DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
3771
|
-
IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
3772
|
-
});
|
|
3773
|
-
|
|
3774
3383
|
// src/lib/contracts-client/storage.ts
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
if (!trimmed)
|
|
3778
|
-
throw new Error("resource must be a non-empty path segment");
|
|
3779
|
-
return `/${trimmed}`;
|
|
3780
|
-
}
|
|
3781
|
-
function entityPath(resource, id) {
|
|
3782
|
-
if (id === undefined || id === null || `${id}`.length === 0) {
|
|
3783
|
-
throw new Error("id must be a non-empty string");
|
|
3784
|
-
}
|
|
3785
|
-
return `${resourcePath(resource)}/${encodeURIComponent(String(id))}`;
|
|
3786
|
-
}
|
|
3787
|
-
function newIdempotencyKey() {
|
|
3788
|
-
const g = globalThis;
|
|
3789
|
-
if (g.crypto?.randomUUID)
|
|
3790
|
-
return g.crypto.randomUUID();
|
|
3791
|
-
return `idmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
3792
|
-
}
|
|
3793
|
-
function extractItems(raw) {
|
|
3794
|
-
if (Array.isArray(raw))
|
|
3795
|
-
return raw;
|
|
3796
|
-
if (raw && typeof raw === "object") {
|
|
3797
|
-
const obj = raw;
|
|
3798
|
-
for (const key of ["items", "data", "results", "rows", "records"]) {
|
|
3799
|
-
if (Array.isArray(obj[key]))
|
|
3800
|
-
return obj[key];
|
|
3801
|
-
}
|
|
3802
|
-
}
|
|
3803
|
-
return [];
|
|
3804
|
-
}
|
|
3805
|
-
function extractTotal(raw) {
|
|
3806
|
-
if (raw && typeof raw === "object") {
|
|
3807
|
-
const obj = raw;
|
|
3808
|
-
for (const key of ["total", "count", "totalCount", "total_count"]) {
|
|
3809
|
-
if (typeof obj[key] === "number")
|
|
3810
|
-
return obj[key];
|
|
3811
|
-
}
|
|
3812
|
-
}
|
|
3813
|
-
return null;
|
|
3814
|
-
}
|
|
3815
|
-
function extractCursor(raw) {
|
|
3816
|
-
if (raw && typeof raw === "object") {
|
|
3817
|
-
const obj = raw;
|
|
3818
|
-
for (const key of ["cursor", "nextCursor", "next_cursor", "next"]) {
|
|
3819
|
-
if (typeof obj[key] === "string")
|
|
3820
|
-
return obj[key];
|
|
3821
|
-
}
|
|
3822
|
-
}
|
|
3823
|
-
return null;
|
|
3824
|
-
}
|
|
3825
|
-
function createHasnaStorageClient(name, transport) {
|
|
3826
|
-
return {
|
|
3827
|
-
name,
|
|
3828
|
-
baseUrl: transport.baseUrl,
|
|
3829
|
-
transport,
|
|
3830
|
-
async list(resource, options = {}) {
|
|
3831
|
-
const raw = await transport.get(resourcePath(resource), options);
|
|
3832
|
-
return {
|
|
3833
|
-
items: extractItems(raw),
|
|
3834
|
-
total: extractTotal(raw),
|
|
3835
|
-
cursor: extractCursor(raw),
|
|
3836
|
-
raw
|
|
3837
|
-
};
|
|
3838
|
-
},
|
|
3839
|
-
async get(resource, id, options = {}) {
|
|
3840
|
-
try {
|
|
3841
|
-
return await transport.get(entityPath(resource, id), options);
|
|
3842
|
-
} catch (error) {
|
|
3843
|
-
if (error instanceof HasnaHttpError && error.status === 404)
|
|
3844
|
-
return null;
|
|
3845
|
-
throw error;
|
|
3846
|
-
}
|
|
3847
|
-
},
|
|
3848
|
-
async create(resource, body, options = {}) {
|
|
3849
|
-
const { idempotencyKey, ...rest } = options;
|
|
3850
|
-
return transport.post(resourcePath(resource), body, {
|
|
3851
|
-
...rest,
|
|
3852
|
-
idempotencyKey: idempotencyKey ?? newIdempotencyKey()
|
|
3853
|
-
});
|
|
3854
|
-
},
|
|
3855
|
-
async update(resource, id, patch, options = {}) {
|
|
3856
|
-
const { method = "PATCH", idempotencyKey, ...rest } = options;
|
|
3857
|
-
const call = method === "PUT" ? transport.put : transport.patch;
|
|
3858
|
-
return call(entityPath(resource, id), patch, { ...rest, ...idempotencyKey ? { idempotencyKey } : {} });
|
|
3859
|
-
},
|
|
3860
|
-
async delete(resource, id, options = {}) {
|
|
3861
|
-
try {
|
|
3862
|
-
await transport.del(entityPath(resource, id), undefined, options);
|
|
3863
|
-
} catch (error) {
|
|
3864
|
-
if (error instanceof HasnaHttpError && error.status === 404)
|
|
3865
|
-
return;
|
|
3866
|
-
throw error;
|
|
3867
|
-
}
|
|
3868
|
-
}
|
|
3869
|
-
};
|
|
3870
|
-
}
|
|
3871
|
-
function resolveStorageClient(name, env = process.env, overrides) {
|
|
3872
|
-
const wired = createClientTransport(name, env, overrides);
|
|
3873
|
-
if (wired.transport === "cloud-http") {
|
|
3874
|
-
return { transport: "cloud-http", client: createHasnaStorageClient(name, wired.client) };
|
|
3875
|
-
}
|
|
3876
|
-
return { transport: "local", client: null };
|
|
3877
|
-
}
|
|
3878
|
-
var init_storage = __esm(() => {
|
|
3879
|
-
init_transport();
|
|
3880
|
-
});
|
|
3384
|
+
import { resolveStorageClient } from "@hasna/contracts/client/storage";
|
|
3385
|
+
var init_storage = () => {};
|
|
3881
3386
|
|
|
3882
3387
|
// src/lib/cloud-storage.ts
|
|
3883
3388
|
function resolveEconomyCloudStorage(env = process.env, overrides) {
|
|
3884
3389
|
const resolved = resolveStorageClient(ECONOMY_APP, env, overrides);
|
|
3885
|
-
return resolved.transport === "
|
|
3390
|
+
return resolved.transport === "http" ? { active: true, client: resolved.client } : { active: false, client: null };
|
|
3886
3391
|
}
|
|
3887
3392
|
function economyCloudStorage(env = process.env) {
|
|
3888
3393
|
if (cache === undefined)
|
|
@@ -6144,11 +5649,11 @@ async function syncAll(db, opts = {}) {
|
|
|
6144
5649
|
const all = !anySpecific;
|
|
6145
5650
|
const result = { deduped: 0 };
|
|
6146
5651
|
if (all || opts.claude) {
|
|
6147
|
-
result.claude = await ingestClaude(db, opts.verbose);
|
|
5652
|
+
result.claude = await ingestClaude(db, opts.verbose, opts.projectsDir);
|
|
6148
5653
|
result.claudeQuota = await ingestClaudeQuota(db, opts.verbose);
|
|
6149
5654
|
}
|
|
6150
5655
|
if (all || opts.takumi)
|
|
6151
|
-
result.takumi = await ingestTakumi(db, opts.verbose);
|
|
5656
|
+
result.takumi = await ingestTakumi(db, opts.verbose, opts.projectsDir);
|
|
6152
5657
|
if (all || opts.codex) {
|
|
6153
5658
|
result.codex = await ingestCodex(db, opts.verbose);
|
|
6154
5659
|
result.codexQuota = await ingestCodexQuota(db, opts.verbose);
|
|
@@ -6451,7 +5956,7 @@ __export(exports_open_projects, {
|
|
|
6451
5956
|
async function syncOpenProjectsRegistry(db, listActiveProjects) {
|
|
6452
5957
|
let listProjects2 = listActiveProjects;
|
|
6453
5958
|
if (!listProjects2) {
|
|
6454
|
-
const projectsApi = await import(
|
|
5959
|
+
const projectsApi = await import(PROJECTS_MODULE);
|
|
6455
5960
|
listProjects2 = projectsApi.listProjects;
|
|
6456
5961
|
}
|
|
6457
5962
|
const projects = listProjects2({ status: "active", limit: 5000 });
|
|
@@ -6475,7 +5980,7 @@ async function syncOpenProjectsRegistry(db, listActiveProjects) {
|
|
|
6475
5980
|
}
|
|
6476
5981
|
return { imported, skipped };
|
|
6477
5982
|
}
|
|
6478
|
-
var resolveProjectPath = (project) => project.primary_path || project.path || "";
|
|
5983
|
+
var resolveProjectPath = (project) => project.primary_path || project.path || "", PROJECTS_MODULE = "@hasna/projects";
|
|
6479
5984
|
var init_open_projects = __esm(() => {
|
|
6480
5985
|
init_database();
|
|
6481
5986
|
});
|
|
@@ -6489,9 +5994,9 @@ __export(exports_config, {
|
|
|
6489
5994
|
getConfigValue: () => getConfigValue
|
|
6490
5995
|
});
|
|
6491
5996
|
import { existsSync as existsSync13, readFileSync as readFileSync11, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
|
|
6492
|
-
import { dirname as dirname2, join as
|
|
5997
|
+
import { dirname as dirname2, join as join14 } from "path";
|
|
6493
5998
|
function getConfigPath() {
|
|
6494
|
-
return process.env["HASNA_ECONOMY_CONFIG_PATH"] ??
|
|
5999
|
+
return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join14(getDataDir(), "config.json");
|
|
6495
6000
|
}
|
|
6496
6001
|
function loadConfig2() {
|
|
6497
6002
|
try {
|
|
@@ -6880,7 +6385,7 @@ function sqliteToPostgres(sql) {
|
|
|
6880
6385
|
// src/db/sync-pg.ts
|
|
6881
6386
|
import { Worker, MessageChannel, receiveMessageOnPort } from "worker_threads";
|
|
6882
6387
|
import { fileURLToPath } from "url";
|
|
6883
|
-
import { dirname as dirname3, join as
|
|
6388
|
+
import { dirname as dirname3, join as join15 } from "path";
|
|
6884
6389
|
import { existsSync as existsSync15 } from "fs";
|
|
6885
6390
|
function translateSqliteDates(sql) {
|
|
6886
6391
|
let s = sql;
|
|
@@ -6906,9 +6411,9 @@ function translateSqliteDates(sql) {
|
|
|
6906
6411
|
function resolveWorkerPath() {
|
|
6907
6412
|
const here = dirname3(fileURLToPath(import.meta.url));
|
|
6908
6413
|
const candidates = [
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6414
|
+
join15(here, "pg-sync-worker.ts"),
|
|
6415
|
+
join15(here, "pg-sync-worker.js"),
|
|
6416
|
+
join15(here, "..", "db", "pg-sync-worker.js")
|
|
6912
6417
|
];
|
|
6913
6418
|
for (const candidate of candidates)
|
|
6914
6419
|
if (existsSync15(candidate))
|
|
@@ -7014,13 +6519,18 @@ var init_pg_migrate = () => {};
|
|
|
7014
6519
|
var init_pg_migrations = () => {};
|
|
7015
6520
|
|
|
7016
6521
|
// src/db/cloud.ts
|
|
7017
|
-
import { assertNoLegacyStorageMode } from "@hasna/contracts/server-backend";
|
|
7018
6522
|
import pg2 from "pg";
|
|
6523
|
+
function assertNoRetiredBackendKey(env) {
|
|
6524
|
+
const legacyKey = RETIRED_BACKEND_KEYS.find((key) => Object.hasOwn(env, key) && env[key] !== undefined);
|
|
6525
|
+
if (!legacyKey)
|
|
6526
|
+
return;
|
|
6527
|
+
throw new Error(`${legacyKey} was removed. Delete the retired variable; ` + "set HASNA_ECONOMY_DATABASE_URL to select the postgresql server backend, " + "or leave it unset for sqlite.");
|
|
6528
|
+
}
|
|
7019
6529
|
function getCloudDatabaseUrl(env = process.env) {
|
|
7020
6530
|
return env["HASNA_ECONOMY_DATABASE_URL"]?.trim() || env["ECONOMY_DATABASE_URL"]?.trim() || env["DATABASE_URL"]?.trim() || undefined;
|
|
7021
6531
|
}
|
|
7022
6532
|
function resolveEconomyServerBackend(env = process.env) {
|
|
7023
|
-
|
|
6533
|
+
assertNoRetiredBackendKey(env);
|
|
7024
6534
|
return getCloudDatabaseUrl(env) ? "postgresql" : "sqlite";
|
|
7025
6535
|
}
|
|
7026
6536
|
function isPostgresBackend(env = process.env) {
|
|
@@ -7059,10 +6569,17 @@ function authClientFromPool(pool) {
|
|
|
7059
6569
|
}
|
|
7060
6570
|
};
|
|
7061
6571
|
}
|
|
6572
|
+
var RETIRED_BACKEND_KEYS;
|
|
7062
6573
|
var init_cloud = __esm(() => {
|
|
7063
6574
|
init_sync_pg();
|
|
7064
6575
|
init_pg_migrate();
|
|
7065
6576
|
init_pg_migrations();
|
|
6577
|
+
RETIRED_BACKEND_KEYS = [
|
|
6578
|
+
"HASNA_ECONOMY_STORAGE_MODE",
|
|
6579
|
+
"HASNA_ECONOMY_MODE",
|
|
6580
|
+
"ECONOMY_STORAGE_MODE",
|
|
6581
|
+
"ECONOMY_MODE"
|
|
6582
|
+
];
|
|
7066
6583
|
});
|
|
7067
6584
|
|
|
7068
6585
|
// src/openapi.ts
|
|
@@ -7076,13 +6593,9 @@ var init_openapi = __esm(() => {
|
|
|
7076
6593
|
description: "AI coding cost tracker \u2014 self-hosted control plane. The server data backend is sqlite | postgresql, selected by the presence of a database URL; with postgresql the serve process reads AND writes PostgreSQL directly with API-key auth via @hasna/contracts. All /v1 routes require a valid `economy:*` scoped API key (x-api-key or Authorization: Bearer). The foundation probes /health, /ready, /version are open."
|
|
7077
6594
|
},
|
|
7078
6595
|
servers: [
|
|
7079
|
-
{
|
|
7080
|
-
url: "https://economy.hasna.xyz",
|
|
7081
|
-
description: "prod"
|
|
7082
|
-
},
|
|
7083
6596
|
{
|
|
7084
6597
|
url: "http://localhost:3456",
|
|
7085
|
-
description: "local"
|
|
6598
|
+
description: "local (self-hosted default; set your own URL when deploying)"
|
|
7086
6599
|
}
|
|
7087
6600
|
],
|
|
7088
6601
|
security: [
|
|
@@ -9101,7 +8614,24 @@ function createHandler(db, options = {}) {
|
|
|
9101
8614
|
const body = await jsonBody(req);
|
|
9102
8615
|
if (!body)
|
|
9103
8616
|
return err("invalid JSON body");
|
|
9104
|
-
|
|
8617
|
+
const result = bulkIngest(db, body);
|
|
8618
|
+
const reqRows = Array.isArray(body?.["requests"]) ? body["requests"] : [];
|
|
8619
|
+
const touchedSessions = new Set;
|
|
8620
|
+
for (const r of reqRows) {
|
|
8621
|
+
const sid = r?.["session_id"];
|
|
8622
|
+
if (typeof sid === "string" && sid)
|
|
8623
|
+
touchedSessions.add(sid);
|
|
8624
|
+
}
|
|
8625
|
+
for (const sid of touchedSessions) {
|
|
8626
|
+
const hasReq = db.prepare(`SELECT COUNT(*) AS c FROM requests WHERE session_id = ?`).get(sid);
|
|
8627
|
+
if (Number(hasReq?.c ?? 0) > 0)
|
|
8628
|
+
rollupSession(db, sid);
|
|
8629
|
+
}
|
|
8630
|
+
try {
|
|
8631
|
+
const { checkAndFireWebhooks: checkAndFireWebhooks2 } = await Promise.resolve().then(() => (init_webhooks(), exports_webhooks));
|
|
8632
|
+
await checkAndFireWebhooks2(db);
|
|
8633
|
+
} catch {}
|
|
8634
|
+
return ok(result);
|
|
9105
8635
|
}
|
|
9106
8636
|
if (path === "/api/usage" && method === "GET") {
|
|
9107
8637
|
const period = url.searchParams.get("period") ?? "month";
|
|
@@ -9290,7 +8820,7 @@ function startServer(port = 3456, options = {}) {
|
|
|
9290
8820
|
handlerOptions.authenticator = verifyApiKey({
|
|
9291
8821
|
app: APP,
|
|
9292
8822
|
signingSecret,
|
|
9293
|
-
|
|
8823
|
+
keyStatus: keys.keyStatus,
|
|
9294
8824
|
audit: (event) => log(JSON.stringify({ evt: "api_auth", outcome: event.outcome, kid: event.kid, reason: event.reason, path: event.path, status: event.status }))
|
|
9295
8825
|
});
|
|
9296
8826
|
} else if (!isLocalHost(hostname2)) {
|
|
@@ -9357,7 +8887,7 @@ import chalk6 from "chalk";
|
|
|
9357
8887
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
9358
8888
|
import { cpSync, existsSync as existsSync17, mkdirSync as mkdirSync4, rmSync, writeFileSync as writeFileSync3 } from "fs";
|
|
9359
8889
|
import { tmpdir, arch } from "os";
|
|
9360
|
-
import { join as
|
|
8890
|
+
import { join as join16 } from "path";
|
|
9361
8891
|
function getArch() {
|
|
9362
8892
|
return arch() === "arm64" ? "arm64" : "x86_64";
|
|
9363
8893
|
}
|
|
@@ -9399,8 +8929,8 @@ async function menubarInstall(opts) {
|
|
|
9399
8929
|
console.error(chalk6.red(`\u2717 Failed to fetch release info: ${e instanceof Error ? e.message : String(e)}`));
|
|
9400
8930
|
process.exit(1);
|
|
9401
8931
|
}
|
|
9402
|
-
const zipPath =
|
|
9403
|
-
const extractDir =
|
|
8932
|
+
const zipPath = join16(tmpdir(), `economy-bar-${cpuArch}.zip`);
|
|
8933
|
+
const extractDir = join16(tmpdir(), "economy-bar-extracted");
|
|
9404
8934
|
console.log(chalk6.cyan(`\u2192 Downloading ${assetUrl}...`));
|
|
9405
8935
|
try {
|
|
9406
8936
|
const res = await fetch(assetUrl, { signal: AbortSignal.timeout(60000) });
|
|
@@ -9420,7 +8950,7 @@ async function menubarInstall(opts) {
|
|
|
9420
8950
|
execFileSync3("unzip", ["-q", zipPath, "-d", extractDir], { stdio: "ignore" });
|
|
9421
8951
|
if (isInstalled())
|
|
9422
8952
|
rmSync(APP_PATH, { recursive: true, force: true });
|
|
9423
|
-
cpSync(
|
|
8953
|
+
cpSync(join16(extractDir, "Economy Bar.app"), APP_PATH, { recursive: true });
|
|
9424
8954
|
try {
|
|
9425
8955
|
execFileSync3("xattr", ["-rd", "com.apple.quarantine", APP_PATH], { stdio: "ignore" });
|
|
9426
8956
|
} catch {}
|
|
@@ -11075,7 +10605,7 @@ function registerExtendedCommands(program) {
|
|
|
11075
10605
|
console.log(" 7. Linux status: economy tui --watch | economy waybar");
|
|
11076
10606
|
console.log();
|
|
11077
10607
|
console.log(" Self-hosted/cloud mode (shared cloud API \u2014 reads/writes route to it):");
|
|
11078
|
-
console.log(" a. API URL: export HASNA_ECONOMY_API_URL=https://economy.
|
|
10608
|
+
console.log(" a. API URL: export HASNA_ECONOMY_API_URL=https://your-economy-host.example.com/v1");
|
|
11079
10609
|
console.log(" b. API key: export HASNA_ECONOMY_API_KEY=<bearer key> (never a DB DSN)");
|
|
11080
10610
|
console.log();
|
|
11081
10611
|
});
|
|
@@ -11296,14 +10826,171 @@ init_billing();
|
|
|
11296
10826
|
init_package_metadata();
|
|
11297
10827
|
init_pricing();
|
|
11298
10828
|
init_store();
|
|
11299
|
-
init_billing_diff();
|
|
11300
|
-
init_transport();
|
|
11301
10829
|
import { execSync } from "child_process";
|
|
10830
|
+
|
|
10831
|
+
// src/lib/cloud-ingest.ts
|
|
10832
|
+
init_database();
|
|
10833
|
+
init_pricing();
|
|
10834
|
+
init_sync_all();
|
|
10835
|
+
init_billing();
|
|
10836
|
+
import { join as join13 } from "path";
|
|
10837
|
+
|
|
10838
|
+
// src/lib/sync-maintenance.ts
|
|
10839
|
+
init_database();
|
|
10840
|
+
function backfillMachineId(db) {
|
|
10841
|
+
const machine = getMachineId();
|
|
10842
|
+
const reqCount = db.prepare(`UPDATE requests SET machine_id = ? WHERE machine_id = '' OR machine_id IS NULL`).run(machine);
|
|
10843
|
+
const sessCount = db.prepare(`UPDATE sessions SET machine_id = ? WHERE machine_id = '' OR machine_id IS NULL`).run(machine);
|
|
10844
|
+
return { requests: Number(reqCount.changes), sessions: Number(sessCount.changes) };
|
|
10845
|
+
}
|
|
10846
|
+
async function recalculateZeroCostRequests(db) {
|
|
10847
|
+
const { computeCostFromDb: computeCostFromDb2 } = await Promise.resolve().then(() => (init_pricing(), exports_pricing));
|
|
10848
|
+
const zeroRows = db.prepare(`SELECT id, model, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, cache_create_5m_tokens, cache_create_1h_tokens
|
|
10849
|
+
FROM requests
|
|
10850
|
+
WHERE cost_usd = 0
|
|
10851
|
+
AND (input_tokens > 0 OR output_tokens > 0 OR cache_read_tokens > 0 OR cache_create_tokens > 0)`).all();
|
|
10852
|
+
let fixed = 0;
|
|
10853
|
+
for (const r of zeroRows) {
|
|
10854
|
+
const cache5m = r.cache_create_5m_tokens ?? r.cache_create_tokens;
|
|
10855
|
+
const cost = computeCostFromDb2(db, r.model, r.input_tokens, r.output_tokens, r.cache_read_tokens, cache5m, r.cache_create_1h_tokens ?? 0);
|
|
10856
|
+
if (cost > 0) {
|
|
10857
|
+
db.prepare(`UPDATE requests SET cost_usd = ? WHERE id = ?`).run(cost, r.id);
|
|
10858
|
+
fixed++;
|
|
10859
|
+
}
|
|
10860
|
+
}
|
|
10861
|
+
if (fixed > 0) {
|
|
10862
|
+
const touchedSessions = new Set(zeroRows.map((r) => {
|
|
10863
|
+
const row = db.prepare(`SELECT session_id FROM requests WHERE id = ?`).get(r.id);
|
|
10864
|
+
return row?.session_id;
|
|
10865
|
+
}).filter(Boolean));
|
|
10866
|
+
for (const sid of touchedSessions)
|
|
10867
|
+
rollupSession(db, sid);
|
|
10868
|
+
}
|
|
10869
|
+
return { fixed, total: zeroRows.length, buckets: queryZeroCostTokenizedModels(db, 8) };
|
|
10870
|
+
}
|
|
10871
|
+
|
|
10872
|
+
// src/lib/cloud-ingest.ts
|
|
10873
|
+
var CLOUD_INGEST_TABLES = ["requests", "sessions", "billing_daily", "usage_snapshots", "subscriptions"];
|
|
10874
|
+
function getIngestCachePath() {
|
|
10875
|
+
if (process.env["HASNA_ECONOMY_INGEST_CACHE"])
|
|
10876
|
+
return process.env["HASNA_ECONOMY_INGEST_CACHE"];
|
|
10877
|
+
return join13(getDataDir(), "ingest-cache.db");
|
|
10878
|
+
}
|
|
10879
|
+
function copyIngestState(from, to) {
|
|
10880
|
+
const rows = from.prepare(`SELECT source, key, value FROM ingest_state`).all();
|
|
10881
|
+
to.prepare(`DELETE FROM ingest_state`).run();
|
|
10882
|
+
const insert = to.prepare(`INSERT INTO ingest_state (source, key, value) VALUES (?, ?, ?)`);
|
|
10883
|
+
for (const row of rows)
|
|
10884
|
+
insert.run(row.source, row.key, row.value);
|
|
10885
|
+
}
|
|
10886
|
+
function exportIngestRows(db) {
|
|
10887
|
+
const body = {};
|
|
10888
|
+
for (const table2 of CLOUD_INGEST_TABLES) {
|
|
10889
|
+
const rows = db.prepare(`SELECT * FROM ${table2}`).all();
|
|
10890
|
+
if (rows.length > 0)
|
|
10891
|
+
body[table2] = rows;
|
|
10892
|
+
}
|
|
10893
|
+
return body;
|
|
10894
|
+
}
|
|
10895
|
+
function unwrapEnvelope(raw) {
|
|
10896
|
+
if (raw && typeof raw === "object" && "data" in raw) {
|
|
10897
|
+
const data = raw.data;
|
|
10898
|
+
return data && typeof data === "object" ? data : {};
|
|
10899
|
+
}
|
|
10900
|
+
return raw ?? {};
|
|
10901
|
+
}
|
|
10902
|
+
async function postIngest(cloud, body) {
|
|
10903
|
+
const raw = await cloud.client.transport.post("/ingest", body);
|
|
10904
|
+
const payload = unwrapEnvelope(raw);
|
|
10905
|
+
return {
|
|
10906
|
+
ingested: payload["ingested"] ?? {},
|
|
10907
|
+
total: Number(payload["total"] ?? 0)
|
|
10908
|
+
};
|
|
10909
|
+
}
|
|
10910
|
+
async function syncAllToCloud(cloud, opts = {}) {
|
|
10911
|
+
const scratch = openDatabase(":memory:", true);
|
|
10912
|
+
ensurePricingSeeded(scratch);
|
|
10913
|
+
const cache2 = openDatabase(opts.cachePath ?? getIngestCachePath(), true);
|
|
10914
|
+
try {
|
|
10915
|
+
if (opts.force)
|
|
10916
|
+
cache2.prepare(`DELETE FROM ingest_state`).run();
|
|
10917
|
+
copyIngestState(cache2, scratch);
|
|
10918
|
+
const result = await syncAll(scratch, opts);
|
|
10919
|
+
if (opts.backfillMachine)
|
|
10920
|
+
backfillMachineId(scratch);
|
|
10921
|
+
if (opts.recalculate)
|
|
10922
|
+
await recalculateZeroCostRequests(scratch);
|
|
10923
|
+
const body = exportIngestRows(scratch);
|
|
10924
|
+
if (Object.keys(body).length === 0) {
|
|
10925
|
+
copyIngestState(scratch, cache2);
|
|
10926
|
+
return { ...result, posted: false };
|
|
10927
|
+
}
|
|
10928
|
+
const response = await postIngest(cloud, body);
|
|
10929
|
+
copyIngestState(scratch, cache2);
|
|
10930
|
+
return { ...result, posted: true, ingested: response.ingested, total: response.total };
|
|
10931
|
+
} finally {
|
|
10932
|
+
try {
|
|
10933
|
+
scratch.close();
|
|
10934
|
+
} catch {}
|
|
10935
|
+
try {
|
|
10936
|
+
cache2.close();
|
|
10937
|
+
} catch {}
|
|
10938
|
+
}
|
|
10939
|
+
}
|
|
10940
|
+
async function billingSyncToCloud(cloud, opts = {}) {
|
|
10941
|
+
const scratch = openDatabase(":memory:", true);
|
|
10942
|
+
try {
|
|
10943
|
+
const days = opts.days ?? 31;
|
|
10944
|
+
const providers = {};
|
|
10945
|
+
const attempts = [
|
|
10946
|
+
["anthropic", () => syncAnthropicBilling(scratch, { days })],
|
|
10947
|
+
["openai", () => syncOpenAIBilling(scratch, { days })],
|
|
10948
|
+
["gemini", () => syncGeminiBilling(scratch, { days })]
|
|
10949
|
+
];
|
|
10950
|
+
const anySelected = opts.anthropic || opts.openai || opts.gemini;
|
|
10951
|
+
let totalUsd = 0;
|
|
10952
|
+
for (const [provider, run] of attempts) {
|
|
10953
|
+
if (anySelected && !opts[provider])
|
|
10954
|
+
continue;
|
|
10955
|
+
{
|
|
10956
|
+
try {
|
|
10957
|
+
const r = await run();
|
|
10958
|
+
providers[provider] = { ok: true, days: r.days, totalUsd: r.totalUsd };
|
|
10959
|
+
totalUsd += r.totalUsd;
|
|
10960
|
+
} catch (e) {
|
|
10961
|
+
providers[provider] = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
10962
|
+
}
|
|
10963
|
+
}
|
|
10964
|
+
}
|
|
10965
|
+
const body = exportIngestRows(scratch);
|
|
10966
|
+
if (Object.keys(body).length === 0)
|
|
10967
|
+
return { providers, totalUsd, posted: false };
|
|
10968
|
+
const response = await postIngest(cloud, body);
|
|
10969
|
+
return { providers, totalUsd, posted: true, ingested: response.ingested, total: response.total };
|
|
10970
|
+
} finally {
|
|
10971
|
+
try {
|
|
10972
|
+
scratch.close();
|
|
10973
|
+
} catch {}
|
|
10974
|
+
}
|
|
10975
|
+
}
|
|
10976
|
+
|
|
10977
|
+
// src/cli/index.ts
|
|
10978
|
+
init_cloud_storage();
|
|
10979
|
+
init_billing_diff();
|
|
10980
|
+
|
|
10981
|
+
// src/lib/contracts-client/transport.ts
|
|
10982
|
+
import { HasnaHttpError } from "@hasna/contracts/client";
|
|
10983
|
+
|
|
10984
|
+
// src/cli/index.ts
|
|
11302
10985
|
var program = new Command;
|
|
11303
10986
|
program.name("economy").description("AI coding cost tracker \u2014 Claude, Takumi, Codex, Gemini, OpenCode, Cursor, Pi, Hermes").version(packageMetadata.version);
|
|
11304
10987
|
async function autoSync(opts = {}) {
|
|
11305
|
-
if (isCloudStore())
|
|
10988
|
+
if (isCloudStore()) {
|
|
10989
|
+
const cloud = economyCloudStorage();
|
|
10990
|
+
if (cloud.active)
|
|
10991
|
+
await syncAllToCloud(cloud, opts);
|
|
11306
10992
|
return;
|
|
10993
|
+
}
|
|
11307
10994
|
const db = openDatabase();
|
|
11308
10995
|
ensurePricingSeeded(db);
|
|
11309
10996
|
await syncAll(db, opts);
|
|
@@ -11518,7 +11205,34 @@ program.action(async () => {
|
|
|
11518
11205
|
});
|
|
11519
11206
|
program.command("sync").description("Ingest cost data from all coding agents").option("--claude", "Only ingest Claude Code telemetry").option("--takumi", "Only ingest Takumi sessions").option("--codex", "Only ingest Codex sessions").option("--gemini", "Only ingest Gemini CLI sessions").option("--opencode", "Only ingest OpenCode sessions").option("--cursor", "Only ingest Cursor usage").option("--pi", "Only ingest Pi sessions").option("--hermes", "Only ingest Hermes sessions").option("--loops", "Only ingest OpenLoops orchestration/judge token usage").option("-v, --verbose", "Verbose output").option("--force", "Force re-process all files (ignore mtime cache)").option("--backfill-machine", "Tag existing records that have no machine_id with current hostname").option("--recalculate", "Recalculate costs for all requests with cost_usd = 0").action(async (opts) => {
|
|
11520
11207
|
if (isCloudStore()) {
|
|
11521
|
-
|
|
11208
|
+
const cloud = economyCloudStorage();
|
|
11209
|
+
if (!cloud.active) {
|
|
11210
|
+
console.log(chalk7.yellow("cloud mode: sync transport unavailable (set HASNA_ECONOMY_API_URL and HASNA_ECONOMY_API_KEY)"));
|
|
11211
|
+
return;
|
|
11212
|
+
}
|
|
11213
|
+
const result2 = await syncAllToCloud(cloud, {
|
|
11214
|
+
claude: opts.claude,
|
|
11215
|
+
takumi: opts.takumi,
|
|
11216
|
+
codex: opts.codex,
|
|
11217
|
+
gemini: opts.gemini,
|
|
11218
|
+
opencode: opts.opencode,
|
|
11219
|
+
cursor: opts.cursor,
|
|
11220
|
+
pi: opts.pi,
|
|
11221
|
+
hermes: opts.hermes,
|
|
11222
|
+
loops: opts.loops,
|
|
11223
|
+
verbose: opts.verbose,
|
|
11224
|
+
force: opts.force,
|
|
11225
|
+
backfillMachine: opts.backfillMachine,
|
|
11226
|
+
recalculate: opts.recalculate
|
|
11227
|
+
});
|
|
11228
|
+
if (result2.posted) {
|
|
11229
|
+
const parts = Object.entries(result2.ingested ?? {}).filter(([, n]) => n > 0).map(([table2, n]) => `${n} ${table2}`);
|
|
11230
|
+
console.log(chalk7.green(`\u2713 pushed ${parts.join(", ")} to the shared API (${result2.total} rows)`));
|
|
11231
|
+
} else {
|
|
11232
|
+
console.log(chalk7.dim("\u2713 no new provider data to push (mtime cache current)"));
|
|
11233
|
+
}
|
|
11234
|
+
console.log(chalk7.bold.green(`
|
|
11235
|
+
\u2713 Sync complete`));
|
|
11522
11236
|
return;
|
|
11523
11237
|
}
|
|
11524
11238
|
const db = openDatabase();
|
|
@@ -11557,34 +11271,14 @@ program.command("sync").description("Ingest cost data from all coding agents").o
|
|
|
11557
11271
|
console.log(chalk7.green(`\u2713 deduped ${result.deduped}`));
|
|
11558
11272
|
if (opts.backfillMachine) {
|
|
11559
11273
|
const machine = getMachineId();
|
|
11560
|
-
const
|
|
11561
|
-
|
|
11562
|
-
console.log(chalk7.cyan(`\u2192 Backfilled machine_id='${machine}': ${reqCount.changes} requests, ${sessCount.changes} sessions`));
|
|
11274
|
+
const r = backfillMachineId(db);
|
|
11275
|
+
console.log(chalk7.cyan(`\u2192 Backfilled machine_id='${machine}': ${r.requests} requests, ${r.sessions} sessions`));
|
|
11563
11276
|
}
|
|
11564
11277
|
if (opts.recalculate) {
|
|
11565
|
-
const {
|
|
11566
|
-
const
|
|
11567
|
-
|
|
11568
|
-
|
|
11569
|
-
const cache5m = r.cache_create_5m_tokens ?? r.cache_create_tokens;
|
|
11570
|
-
const cost = computeCostFromDb2(db, r.model, r.input_tokens, r.output_tokens, r.cache_read_tokens, cache5m, r.cache_create_1h_tokens ?? 0);
|
|
11571
|
-
if (cost > 0) {
|
|
11572
|
-
db.prepare(`UPDATE requests SET cost_usd = ? WHERE id = ?`).run(cost, r.id);
|
|
11573
|
-
fixed++;
|
|
11574
|
-
}
|
|
11575
|
-
}
|
|
11576
|
-
if (fixed > 0) {
|
|
11577
|
-
const touchedSessions = new Set(zeroRows.map((r) => {
|
|
11578
|
-
const row = db.prepare(`SELECT session_id FROM requests WHERE id = ?`).get(r.id);
|
|
11579
|
-
return row?.session_id;
|
|
11580
|
-
}).filter(Boolean));
|
|
11581
|
-
const { rollupSession: rollupSession2 } = await Promise.resolve().then(() => (init_database(), exports_database));
|
|
11582
|
-
for (const sid of touchedSessions) {
|
|
11583
|
-
rollupSession2(db, sid);
|
|
11584
|
-
}
|
|
11585
|
-
}
|
|
11586
|
-
console.log(chalk7.cyan(`\u2192 Recalculated: ${fixed}/${zeroRows.length} zero-cost requests now have pricing`));
|
|
11587
|
-
const zeroCostBuckets = queryZeroCostTokenizedModels(db, 8);
|
|
11278
|
+
const { getPricingFromDb: getPricingFromDb2 } = await Promise.resolve().then(() => (init_pricing(), exports_pricing));
|
|
11279
|
+
const r = await recalculateZeroCostRequests(db);
|
|
11280
|
+
console.log(chalk7.cyan(`\u2192 Recalculated: ${r.fixed}/${r.total} zero-cost requests now have pricing`));
|
|
11281
|
+
const zeroCostBuckets = r.buckets;
|
|
11588
11282
|
if (zeroCostBuckets.length > 0) {
|
|
11589
11283
|
console.log(chalk7.yellow("\u2192 Zero-cost token buckets remain:"));
|
|
11590
11284
|
for (const row of zeroCostBuckets) {
|
|
@@ -12410,13 +12104,26 @@ program.command("remove <type> <id>").alias("rm").description("Remove a record.
|
|
|
12410
12104
|
});
|
|
12411
12105
|
var billingCmd = program.command("billing").description("Pull actual billing from provider billing sources (ground truth)");
|
|
12412
12106
|
billingCmd.command("sync").description("Sync actual billing from Anthropic, OpenAI, and Gemini billing sources").option("--days <n>", "Days of history to fetch", "31").option("--anthropic", "Only sync Anthropic").option("--openai", "Only sync OpenAI").option("--gemini", "Only sync Gemini").action(async (opts) => {
|
|
12413
|
-
if (isCloudStore()) {
|
|
12414
|
-
console.log(chalk7.yellow("cloud mode: billing ingest is a local-only operation; nothing to sync (reads route to the cloud API)"));
|
|
12415
|
-
return;
|
|
12416
|
-
}
|
|
12417
12107
|
const days = parsePositiveCliInteger(opts.days ?? "31", "--days");
|
|
12418
12108
|
if (days > 366)
|
|
12419
12109
|
fail("--days must be between 1 and 366");
|
|
12110
|
+
if (isCloudStore()) {
|
|
12111
|
+
const cloud = economyCloudStorage();
|
|
12112
|
+
if (!cloud.active) {
|
|
12113
|
+
console.log(chalk7.yellow("cloud mode: billing sync transport unavailable (set HASNA_ECONOMY_API_URL and HASNA_ECONOMY_API_KEY)"));
|
|
12114
|
+
return;
|
|
12115
|
+
}
|
|
12116
|
+
const result = await billingSyncToCloud(cloud, { days, anthropic: opts.anthropic, openai: opts.openai, gemini: opts.gemini });
|
|
12117
|
+
for (const [provider, p] of Object.entries(result.providers)) {
|
|
12118
|
+
if (p.ok)
|
|
12119
|
+
console.log(chalk7.green(`\u2713 ${provider} billing: ${p.days} days, $${p.totalUsd.toFixed(2)}`));
|
|
12120
|
+
else
|
|
12121
|
+
console.log(chalk7.red(`\u2717 ${provider}: ${p.error}`));
|
|
12122
|
+
}
|
|
12123
|
+
if (result.posted)
|
|
12124
|
+
console.log(chalk7.green(`\u2713 pushed ${result.total} billing rows to the shared API`));
|
|
12125
|
+
return;
|
|
12126
|
+
}
|
|
12420
12127
|
const db = openDatabase();
|
|
12421
12128
|
const doAll = !opts.anthropic && !opts.openai && !opts.gemini;
|
|
12422
12129
|
if (opts.anthropic || doAll) {
|