@ychris12138/dsh-usage-stats 0.3.0 → 0.3.1
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 +21 -23
- package/SECURITY.md +1 -1
- package/docs/release-checklist.md +16 -11
- package/docs/release-notes-v0.3.1.md +40 -0
- package/lib/accounts.js +3 -1
- package/lib/balance.js +115 -12
- package/lib/client.js +159 -317
- package/lib/index.js +77 -7
- package/lib/orcarouter.js +79 -0
- package/lib/provider-identity.js +3 -0
- package/package.json +5 -3
package/lib/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-usage-stats — server half.
|
|
3
3
|
*
|
|
4
|
-
* Registers nine read-only, loopback-only endpoints
|
|
4
|
+
* Registers nine read-only, loopback-only data endpoints plus one explicit
|
|
5
|
+
* loopback-only OrcaRouter settings action on the web server:
|
|
5
6
|
* GET /api/usage-stats/usage — per-day token usage across every session
|
|
6
7
|
* GET /api/usage-stats/providers — configured providers + balance schemes
|
|
7
8
|
* GET /api/usage-stats/balance — balance for one provider (?provider=<id>)
|
|
@@ -11,6 +12,7 @@
|
|
|
11
12
|
* GET /api/usage-stats/export/daily.csv — daily provider/model usage export
|
|
12
13
|
* GET /api/usage-stats/export/sessions.csv — per-session usage export
|
|
13
14
|
* GET /api/usage-stats/export.json — versioned usage/account-safe export
|
|
15
|
+
* GET|POST /api/usage-stats/integrations/orcarouter — status / explicit add
|
|
14
16
|
*
|
|
15
17
|
* Provider configuration is read straight from the harness settings
|
|
16
18
|
* (`llm-deepseek` for the official DeepSeek route, `llm-pi-ai` for every
|
|
@@ -41,6 +43,7 @@ import { applyUsageDelta, createUsageState, currentSessionContext, mergeBillingI
|
|
|
41
43
|
import { ACCOUNT_REFRESH_MS, createAccountService, validateAccountConfig } from "./accounts.js";
|
|
42
44
|
import { changedProviderPricingRoutes, createUsageCostEstimator, parseCostAccumulator, pricingFingerprint, renderBudgetSummary, serializeCostAccumulator, validateBudgetConfig } from "./billing.js";
|
|
43
45
|
import { dailyCsv, jsonExport, sessionsCsv } from "./export.js";
|
|
46
|
+
import { addOrcaRouterPreset, orcaRouterIntegrationState } from "./orcarouter.js";
|
|
44
47
|
|
|
45
48
|
/** Stable Cordis plugin name. */
|
|
46
49
|
const name = "usage-stats";
|
|
@@ -57,6 +60,7 @@ const SESSION_CONTEXT_PATH = "/api/usage-stats/session-context";
|
|
|
57
60
|
const DAILY_EXPORT_PATH = "/api/usage-stats/export/daily.csv";
|
|
58
61
|
const SESSIONS_EXPORT_PATH = "/api/usage-stats/export/sessions.csv";
|
|
59
62
|
const JSON_EXPORT_PATH = "/api/usage-stats/export.json";
|
|
63
|
+
const ORCAROUTER_INTEGRATION_PATH = "/api/usage-stats/integrations/orcarouter";
|
|
60
64
|
const UPSTREAM_TIMEOUT_MS = 15000;
|
|
61
65
|
const CACHE_VERSION = 5;
|
|
62
66
|
|
|
@@ -138,6 +142,29 @@ function rejectForeignCaller(req, res) {
|
|
|
138
142
|
return true;
|
|
139
143
|
}
|
|
140
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Fence the sole settings mutation. The custom action header makes this a
|
|
147
|
+
* non-simple browser request, so a foreign page cannot CSRF the loopback route
|
|
148
|
+
* without a CORS preflight (the exact route never grants CORS).
|
|
149
|
+
*/
|
|
150
|
+
function rejectForeignMutation(req, res) {
|
|
151
|
+
if (req.method !== "POST") {
|
|
152
|
+
json(res, 405, { ok: false, error: "method-not-allowed" });
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
const peer = req.socket?.remoteAddress;
|
|
156
|
+
if (!isLoopbackAddress(peer) || !isLoopbackHostHeader(req)) {
|
|
157
|
+
json(res, 403, { ok: false, error: "forbidden" });
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
const contentType = typeof req.headers["content-type"] === "string" ? req.headers["content-type"].toLowerCase() : "";
|
|
161
|
+
if (!contentType.startsWith("application/json") || req.headers["x-dsh-usage-stats-action"] !== "add-orcarouter") {
|
|
162
|
+
json(res, 403, { ok: false, error: "forbidden-action" });
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
141
168
|
//#region incremental cache
|
|
142
169
|
/** Cache file location under the dsh home. */
|
|
143
170
|
function cachePath() {
|
|
@@ -466,7 +493,14 @@ export async function collectUsage(ctx, config = { monitors: {}, budgets: valida
|
|
|
466
493
|
resetUsageState(state);
|
|
467
494
|
}
|
|
468
495
|
const fresh = wasPersisted ? events.filter((event) => event.seq > (state.consumed ?? 0)) : events;
|
|
469
|
-
|
|
496
|
+
// readFrom is inclusive: an unchanged log returns exactly the
|
|
497
|
+
// already-folded cursor event (seq === consumed). An empty
|
|
498
|
+
// fresh slice with the cursor still present is therefore NOT a
|
|
499
|
+
// rewrite — only a log that no longer contains the cursor
|
|
500
|
+
// (truncated/rewritten) must refold from scratch.
|
|
501
|
+
const contiguous = fresh.length === 0
|
|
502
|
+
? wasPersisted && events.some((event) => event.seq === (state.consumed ?? 0))
|
|
503
|
+
: fresh[0].seq === state.consumed + 1;
|
|
470
504
|
if (!contiguous && state.consumed > 0) {
|
|
471
505
|
// Log truncated or rewritten: refold the whole log.
|
|
472
506
|
resetUsageState(state);
|
|
@@ -644,9 +678,8 @@ export async function collectActiveAccountIds(ctx, config = { monitors: {} }) {
|
|
|
644
678
|
async function handleSessionContext(ctx, config, accounts, req, res) {
|
|
645
679
|
if (rejectForeignCaller(req, res)) return;
|
|
646
680
|
try {
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
// a hidden Pill performs no usage fold or account read and renders null.
|
|
681
|
+
// Preserve the v0.3.0 API response for the legacy display flag even though
|
|
682
|
+
// the current client no longer renders any composer UI.
|
|
650
683
|
if (config.display?.currentSessionPill === false) {
|
|
651
684
|
json(res, 200, { ok: true, context: null, display: { currentSessionPill: false } });
|
|
652
685
|
return;
|
|
@@ -703,6 +736,37 @@ async function handleProviders(ctx, accounts, req, res) {
|
|
|
703
736
|
}
|
|
704
737
|
}
|
|
705
738
|
|
|
739
|
+
/** Read secret-free preset state or perform the user's explicit path mutation. */
|
|
740
|
+
async function handleOrcaRouterIntegration(ctx, req, res) {
|
|
741
|
+
if (req.method === "GET") {
|
|
742
|
+
if (rejectForeignCaller(req, res)) return;
|
|
743
|
+
try {
|
|
744
|
+
json(res, 200, { ok: true, integration: orcaRouterIntegrationState(ctx.get("settings")) });
|
|
745
|
+
} catch (error) {
|
|
746
|
+
ctx.logger.warn(`usage-stats: OrcaRouter integration status failed: ${String(error)}`);
|
|
747
|
+
json(res, 500, { ok: false, error: "internal", message: "settings status unavailable" });
|
|
748
|
+
}
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (rejectForeignMutation(req, res)) return;
|
|
752
|
+
try {
|
|
753
|
+
const integration = await addOrcaRouterPreset(ctx.get("settings"));
|
|
754
|
+
if (!integration.available) {
|
|
755
|
+
json(res, 409, { ok: false, error: "settings-unavailable", message: "DSH provider settings are not writable" });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
json(res, 200, { ok: true, integration });
|
|
759
|
+
} catch (error) {
|
|
760
|
+
const conflict = error?.code === "SETTINGS_CONFLICT";
|
|
761
|
+
ctx.logger.warn(`usage-stats: OrcaRouter settings mutation failed (${conflict ? "conflict" : "rejected"})`);
|
|
762
|
+
json(res, conflict ? 409 : 422, {
|
|
763
|
+
ok: false,
|
|
764
|
+
error: conflict ? "settings-conflict" : "settings-update-rejected",
|
|
765
|
+
message: conflict ? "provider settings changed; retry the action" : "DSH rejected the provider preset"
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
706
770
|
async function selectedProviderId(req, accounts) {
|
|
707
771
|
const url = new URL(req.url ?? "/", "http://x");
|
|
708
772
|
const requested = url.searchParams.get("provider");
|
|
@@ -895,7 +959,8 @@ export function startBackgroundRefresh(ctx, accounts, deps = {}) {
|
|
|
895
959
|
}
|
|
896
960
|
|
|
897
961
|
/**
|
|
898
|
-
* Plugin body: register
|
|
962
|
+
* Plugin body: register nine data routes plus the explicit integration route,
|
|
963
|
+
* then start background refresh.
|
|
899
964
|
* @param ctx - plugin context carrying webServer, credentials, sessions, sessionPersistence, settings, and llm.
|
|
900
965
|
*/
|
|
901
966
|
const Config = {
|
|
@@ -967,6 +1032,11 @@ async function apply(ctx, rawConfig = {}, deps = {}) {
|
|
|
967
1032
|
path: SESSION_CONTEXT_PATH,
|
|
968
1033
|
handler: (req, res) => handleSessionContext(ctx, config, accounts, req, res)
|
|
969
1034
|
}), "usage-stats: session context route");
|
|
1035
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1036
|
+
kind: "exact",
|
|
1037
|
+
path: ORCAROUTER_INTEGRATION_PATH,
|
|
1038
|
+
handler: (req, res) => handleOrcaRouterIntegration(ctx, req, res)
|
|
1039
|
+
}), "usage-stats: optional OrcaRouter integration route");
|
|
970
1040
|
ctx.effect(() => ctx.webServer.register({
|
|
971
1041
|
kind: "exact",
|
|
972
1042
|
path: DAILY_EXPORT_PATH,
|
|
@@ -988,4 +1058,4 @@ async function apply(ctx, rawConfig = {}, deps = {}) {
|
|
|
988
1058
|
}), "usage-stats: background usage/account refresh");
|
|
989
1059
|
}
|
|
990
1060
|
|
|
991
|
-
export { apply, Config, inject, name, USAGE_PATH, PROVIDERS_PATH, BALANCE_PATH, SUBSCRIPTIONS_PATH, ACCOUNT_PATH, SESSION_CONTEXT_PATH, DAILY_EXPORT_PATH, SESSIONS_EXPORT_PATH, JSON_EXPORT_PATH, configuredProviders, totalTokens, validateConfig, zeroBuckets };
|
|
1061
|
+
export { apply, Config, inject, name, USAGE_PATH, PROVIDERS_PATH, BALANCE_PATH, SUBSCRIPTIONS_PATH, ACCOUNT_PATH, SESSION_CONTEXT_PATH, DAILY_EXPORT_PATH, SESSIONS_EXPORT_PATH, JSON_EXPORT_PATH, ORCAROUTER_INTEGRATION_PATH, configuredProviders, totalTokens, validateConfig, zeroBuckets };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional OrcaRouter provider preset and its narrow DSH settings mutation.
|
|
3
|
+
*
|
|
4
|
+
* The plugin never edits settings.yaml directly and never installs this route
|
|
5
|
+
* during startup. A caller must explicitly request the single path mutation;
|
|
6
|
+
* an existing `orcarouter` profile always wins unchanged.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-usage-stats/orcarouter
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const SETTINGS_NAMESPACE = "llm-pi-ai";
|
|
12
|
+
const PROVIDER_ID = "orcarouter";
|
|
13
|
+
|
|
14
|
+
export const ORCAROUTER_PROFILE = Object.freeze({
|
|
15
|
+
displayName: "OrcaRouter",
|
|
16
|
+
apiKeyEnv: "ORCAROUTER_API_KEY",
|
|
17
|
+
api: "openai-completions",
|
|
18
|
+
baseURL: "https://api.orcarouter.ai/v1",
|
|
19
|
+
models: Object.freeze([Object.freeze({ id: "orcarouter/auto", name: "OrcaRouter Auto" })])
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function descriptorOf(settings) {
|
|
23
|
+
if (typeof settings?.describe !== "function") return null;
|
|
24
|
+
const descriptors = settings.describe({ redactSecrets: true });
|
|
25
|
+
if (!Array.isArray(descriptors)) return null;
|
|
26
|
+
return descriptors.find((entry) => entry?.ns === SETTINGS_NAMESPACE) ?? null;
|
|
27
|
+
}
|
|
28
|
+
function hasOrcaRouter(descriptor) {
|
|
29
|
+
const providers = descriptor?.value?.providers;
|
|
30
|
+
return providers !== null && typeof providers === "object" && !Array.isArray(providers)
|
|
31
|
+
&& Object.hasOwn(providers, PROVIDER_ID);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Secret-free availability state suitable for the browser integration card. */
|
|
35
|
+
export function orcaRouterIntegrationState(settings) {
|
|
36
|
+
const descriptor = descriptorOf(settings);
|
|
37
|
+
const available = descriptor !== null && typeof settings?.mutate === "function" && settings.writable !== false;
|
|
38
|
+
return {
|
|
39
|
+
available,
|
|
40
|
+
installed: descriptor !== null && hasOrcaRouter(descriptor)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function detachedProfile() {
|
|
45
|
+
return {
|
|
46
|
+
displayName: ORCAROUTER_PROFILE.displayName,
|
|
47
|
+
apiKeyEnv: ORCAROUTER_PROFILE.apiKeyEnv,
|
|
48
|
+
api: ORCAROUTER_PROFILE.api,
|
|
49
|
+
baseURL: ORCAROUTER_PROFILE.baseURL,
|
|
50
|
+
models: ORCAROUTER_PROFILE.models.map((model) => ({ ...model }))
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Add the preset with one revision-guarded path mutation. This preserves all
|
|
56
|
+
* unrelated providers and converges cleanly if another writer adds the same
|
|
57
|
+
* route between our read and write.
|
|
58
|
+
*/
|
|
59
|
+
export async function addOrcaRouterPreset(settings) {
|
|
60
|
+
const before = descriptorOf(settings);
|
|
61
|
+
const available = before !== null && typeof settings?.mutate === "function" && settings.writable !== false;
|
|
62
|
+
if (!available) return { available: false, installed: hasOrcaRouter(before), added: false };
|
|
63
|
+
if (hasOrcaRouter(before)) return { available: true, installed: true, added: false };
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await settings.mutate(SETTINGS_NAMESPACE, [{
|
|
67
|
+
op: "set",
|
|
68
|
+
path: ["providers", PROVIDER_ID],
|
|
69
|
+
value: detachedProfile()
|
|
70
|
+
}], before.revision);
|
|
71
|
+
return { available: true, installed: true, added: true };
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error?.code === "SETTINGS_CONFLICT") {
|
|
74
|
+
const after = descriptorOf(settings);
|
|
75
|
+
if (hasOrcaRouter(after)) return { available: true, installed: true, added: false };
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
package/lib/provider-identity.js
CHANGED
|
@@ -20,6 +20,7 @@ const ADAPTER_IDENTITIES = Object.freeze({
|
|
|
20
20
|
"openrouter-balance": { providerFamily: "openrouter", pricingFamily: "openrouter" },
|
|
21
21
|
"moonshot-balance": { providerFamily: "moonshot", pricingFamily: "moonshot" },
|
|
22
22
|
"zai-balance": { providerFamily: "zai", pricingFamily: "zai" },
|
|
23
|
+
"orcarouter-balance": { providerFamily: "orcarouter", pricingFamily: "unknown" },
|
|
23
24
|
general: { providerFamily: "unknown", pricingFamily: "unknown" },
|
|
24
25
|
"new-api": { providerFamily: "new-api", pricingFamily: "unknown" },
|
|
25
26
|
sub2api: { providerFamily: "sub2api", pricingFamily: "unknown" },
|
|
@@ -48,6 +49,7 @@ const CANONICAL_ROUTES = Object.freeze({
|
|
|
48
49
|
minimaxi: { providerFamily: "minimax", accountAdapter: "minimax-token-plan", balanceScheme: null },
|
|
49
50
|
"minimax-cn": { providerFamily: "minimax", accountAdapter: "minimax-token-plan", balanceScheme: null },
|
|
50
51
|
"minimax-coding": { providerFamily: "minimax", accountAdapter: "minimax-token-plan", balanceScheme: null },
|
|
52
|
+
orcarouter: { providerFamily: "orcarouter", accountAdapter: "orcarouter-balance", pricingFamily: "unknown", balanceScheme: "orcarouter" },
|
|
51
53
|
passion: { providerFamily: "sub2api", accountAdapter: "sub2api", pricingFamily: "unknown", balanceScheme: null }
|
|
52
54
|
});
|
|
53
55
|
|
|
@@ -66,6 +68,7 @@ function hostnameOf(baseURL) {
|
|
|
66
68
|
|
|
67
69
|
function hostRule(hostname) {
|
|
68
70
|
if (hostname === "api.deepseek.com") return { providerFamily: "deepseek", accountAdapter: "deepseek-balance" };
|
|
71
|
+
if (hostname === "api.orcarouter.ai") return { providerFamily: "orcarouter", accountAdapter: "orcarouter-balance", pricingFamily: "unknown" };
|
|
69
72
|
if (hostname === "passionapi.com" || hostname.endsWith(".passionapi.com")) return { providerFamily: "sub2api", accountAdapter: "sub2api", pricingFamily: "unknown" };
|
|
70
73
|
if (hostname === "ollama.com" || hostname.endsWith(".ollama.com")) return { providerFamily: "ollama", accountAdapter: "ollama" };
|
|
71
74
|
return null;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ychris12138/dsh-usage-stats",
|
|
3
3
|
"description": "Token usage, provider accounts, session cost estimates, budgets, and exports for the dsh web GUI",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.1",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/Ychris12138/dsh-usage-stats.git"
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"docs/images/usage-panel.svg",
|
|
24
24
|
"docs/release-checklist.md",
|
|
25
25
|
"docs/release-notes-v0.3.0.md",
|
|
26
|
+
"docs/release-notes-v0.3.1.md",
|
|
26
27
|
"scripts/install.mjs",
|
|
27
28
|
"README.md",
|
|
28
29
|
"LICENSE",
|
|
@@ -51,10 +52,10 @@
|
|
|
51
52
|
}
|
|
52
53
|
},
|
|
53
54
|
"scripts": {
|
|
54
|
-
"check": "npm run check:release && node --check lib/index.js && node --check lib/usage.js && node --check lib/billing.js && node --check lib/pricing.js && node --check lib/network.js && node --check lib/provider-identity.js && node --check lib/balance.js && node --check lib/subscriptions.js && node --check lib/accounts.js && node --check lib/export.js && node --check lib/client.js && node --check scripts/install.mjs && node --check scripts/smoke-client.mjs && node --check scripts/test-overlay-layering.mjs && node --check scripts/test-bundle.mjs && node --check scripts/test-install.mjs && node --check scripts/test-server.mjs && node --check scripts/test-export.mjs && node --check scripts/test-provider-identity.mjs && node --check scripts/test-pricing.mjs && node --check scripts/test-billing.mjs && node --check scripts/test-balance.mjs && node --check scripts/test-subscriptions.mjs && node --check scripts/test-accounts.mjs && node --check scripts/release-metadata.mjs && node --check scripts/check-release-metadata.mjs && node --check scripts/sync-release-version.mjs && node --check scripts/validate-fold.mjs && node --check scripts/verify-raw.mjs && node --check scripts/check-balance.mjs",
|
|
55
|
+
"check": "npm run check:release && node --check lib/index.js && node --check lib/usage.js && node --check lib/billing.js && node --check lib/pricing.js && node --check lib/network.js && node --check lib/provider-identity.js && node --check lib/orcarouter.js && node --check lib/balance.js && node --check lib/subscriptions.js && node --check lib/accounts.js && node --check lib/export.js && node --check lib/client.js && node --check scripts/install.mjs && node --check scripts/smoke-client.mjs && node --check scripts/test-overlay-layering.mjs && node --check scripts/test-bundle.mjs && node --check scripts/test-install.mjs && node --check scripts/test-server.mjs && node --check scripts/test-export.mjs && node --check scripts/test-provider-identity.mjs && node --check scripts/test-orcarouter.mjs && node --check scripts/test-pricing.mjs && node --check scripts/test-billing.mjs && node --check scripts/test-balance.mjs && node --check scripts/test-subscriptions.mjs && node --check scripts/test-accounts.mjs && node --check scripts/release-metadata.mjs && node --check scripts/check-release-metadata.mjs && node --check scripts/sync-release-version.mjs && node --check scripts/validate-fold.mjs && node --check scripts/verify-raw.mjs && node --check scripts/check-balance.mjs",
|
|
55
56
|
"check:release": "node scripts/check-release-metadata.mjs",
|
|
56
57
|
"release:sync": "node scripts/sync-release-version.mjs",
|
|
57
|
-
"test": "npm run test:bundle && npm run test:client && npm run test:overlay && npm run test:server && npm run test:export && npm run test:provider-identity && npm run test:pricing && npm run test:billing && npm run test:balance && npm run test:subscriptions && npm run test:accounts && npm run test:install",
|
|
58
|
+
"test": "npm run test:bundle && npm run test:client && npm run test:overlay && npm run test:server && npm run test:export && npm run test:provider-identity && npm run test:orcarouter && npm run test:pricing && npm run test:billing && npm run test:balance && npm run test:subscriptions && npm run test:accounts && npm run test:install",
|
|
58
59
|
"test:bundle": "node scripts/test-bundle.mjs",
|
|
59
60
|
"test:client": "node scripts/smoke-client.mjs",
|
|
60
61
|
"test:overlay": "node scripts/test-overlay-layering.mjs",
|
|
@@ -62,6 +63,7 @@
|
|
|
62
63
|
"test:server": "node scripts/test-server.mjs",
|
|
63
64
|
"test:export": "node scripts/test-export.mjs",
|
|
64
65
|
"test:provider-identity": "node scripts/test-provider-identity.mjs",
|
|
66
|
+
"test:orcarouter": "node scripts/test-orcarouter.mjs",
|
|
65
67
|
"test:pricing": "node scripts/test-pricing.mjs",
|
|
66
68
|
"test:billing": "node scripts/test-billing.mjs",
|
|
67
69
|
"test:balance": "node scripts/test-balance.mjs",
|