@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/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 on the web server:
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
- const contiguous = fresh.length === 0 ? state.consumed === 0 : fresh[0].seq === state.consumed + 1;
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
- // Client loader entries carry module identity, not the server Cordis
648
- // config. Keep the display policy server-owned on this existing request:
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 the nine exact routes and start background refresh.
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
+ }
@@ -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.0",
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",