Skip to content
dsh-market Browse plugins GitHub 中文

wingsky-1/dsh-plugin-hub#packages/dsh-provider-usage

Multi-provider usage stats framework (v2 adapter contract): persistent capsule plus detail panel; DeepSeek official (interval-bookkeeping daily usage derivation with a peak/valley countdown badge, working even without an official usage endpoint) and OpenCode Go built in; any other data source joins by dropping in a single mjs adapter file, hot-swappable from the settings page, with API keys kept on the host.

Stars ★ 23 Category Usage & Billing Listed 2026-08-18 npm @wingsky-1/dsh-provider-usage

Install

Inside DeepSeek Harness, with dsh-market

dsh plugin --profile web add dshmarket

Or from the command line

dsh plugin --profile web add @wingsky-1/dsh-provider-usage

Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.

README

DSH (DeepSeek Harness) Web GUI plugin: a generic multi-provider usage statistics framework (v2 adapter contract).

A persistent capsule at the top-right of the chat view shows usage for the current model provider; click it to open a detail panel. Two adapters work out of the box — DeepSeek official (balance + peak/valley countdown badge + daily usage estimation) and OpenCode Go (official /v1/usage, three windows); any other data source can be plugged in by writing one mjs adapter file against the v2 contract (detect/add/toggle on the settings page, see "Adapter development guide"). Rendering happens on the host side — adapters return HTML, the client only injects it, and API keys never reach the browser.

Quick install

dsh plugin --profile web add @wingsky-1/dsh-provider-usage

After install / uninstall / update, restart dsh web once (bundle layers are only composed at startup) for changes to take effect.

Core advantages

  • Generic framework, works out of the box: one v2 adapter contract carries any provider; DeepSeek official and OpenCode Go adapters are built in — usage shows up right after install
  • Any data source takes one mjs file: three exports (fetchData / formatCapsule / formatPanel) complete an integration; detect/add/toggle hot-swappable from the settings page, file edits auto hot-reload
  • Daily usage even without a usage API: the built-in DeepSeek adapter estimates daily consumption via interval bookkeeping (pure-consumption intervals = balance drops, directly reconcilable with platform billing; top-ups listed separately, never mixed in; abnormal intervals excluded). The balance line chart auto-breaks its axis at each top-up, plus a peak/valley countdown badge and a 15-day usage bar panel
  • Keys never leave the host: fetching runs host-side with keys kept and used only on the host, never sent to the browser (credential-chain / env injection recommended; an explicit apiKey config persists in host config files, 0600-protected); rendered output goes through double sanitization (esc() escaping duty + structured sanitization fallback) for a two-layer XSS defense
  • Recoverable history, cache-backed performance: daily-sharded JSONL persistence (0600) with age/size auto-cleanup; in-process panel render cache + stats cache + background warmup — repeated requests with unchanged data cost zero recompute

中文文档:README.md(authoritative)。架构图解(flow / sequence charts): docs/architecture.md;适配器开发引导:docs/adapter-guide.md。

Install

Prerequisite: DeepSeek Harness installed and dsh web runnable (if dsh is not globally installed, see below).

Add

dsh plugin --profile web add @wingsky-1/dsh-provider-usage

Remove

dsh plugin --profile web remove @wingsky-1/dsh-provider-usage

Update

dsh plugin --profile web update @wingsky-1/dsh-provider-usage

Restart dsh web once after add / remove / update (bundles are composed at startup only).

Without a global dsh install

npx @deepseek-ai/dsh plugin --profile web add @wingsky-1/dsh-provider-usage

How it works (v2)

Host (Node)                                    Client (browser)
─────────────────────────────                 ─────────────────────
Warmup timer (5min) ─┐
                     ├→ getStats()             60s polling /stats (re-checks the
Client polling ──────┘   │ Mutex                  session provider before each fetch, #71)
                         │ 60s cache           Capsule frame ← capsuleHtml
                         │ 5s fetch timeout     Panel frame ← panelHtml (/history, 90s fallback cache)
                         ↓
                   adapter.fetchData(ctx)   ← user .mjs (apiEndpoint/staticPath/apiKey injected)
                         ↓
                   daily-sharded JSONL history ──→ panel render cache cleared (primary invalidation)
                         ↓
                   adapter.formatCapsule/Panel() → sanitize → HTML

The built-in adapters opencode-go-builtin (OpenCode Go official /v1/usage, three windows) and deepseek-official-builtin (DeepSeek official balance + peak/valley countdown badge, see below) work out of the box.

Provider-follow semantics: switching sessions refreshes immediately; in-session model/provider switches have no host push signal, so every fetch re-validates the provider first — follow within at most one polling cycle (#71).

Configuration

Key Default Meaning
enabled true Plugin switch
adapter none User adapter mjs path (built-in opencode-go by default)
provider opencode-go Model provider name to associate
staticPath none API path (injected into fetchData, appended to apiEndpoint)
apiEndpoint none API base URL (optional; explicit config wins over credential chain)
apiKey none Explicit key (optional; falls back to the credential chain, never echoed in settings)
historyDir <DSH_HOME>/dsh-provider-usage/ History storage root
warmupIntervalMs 300000 Background warmup interval
cacheDurationMs 30000 Cache freshness (ms, floor 5000; lowered from 60000 in #198 so peak/valley badge flips propagate end-to-end within ~95s = 30s host cache + 60s client polling + render margin)
fetchTimeoutMs 5000 Forced fetchData timeout (fixed, not configurable; raised 2s→5s in #208, user values ignored)
autoReload true Hot reload on adapter file edits (enabled by default; set false to disable)

Reports: reasoning effort and bounded retries

The report configuration lives in historyDir/reports/config.json. provider, model, three period schedules/templates, notification, and directory scope keep their existing meanings. reasoningEffort is optional: when unset, the request omits the field and keeps DSH's default. When set, it is an opaque ID validated against the current exact model's DSH capability response before any model request. The plugin preserves DSH's order, names, and default marker; it never maps to low/off or infers the last array entry. A temporarily unavailable capability keeps the saved value and shows a warning instead of silently clearing or falling back.

  • Budget and backoff: after the initial report call, at most 5 automatic retries are allowed (attempts=0..5), with 1/2/4/8/16-minute backoff before retries 1–5. Only classified transient failures and empty output (reasoning-only or fully empty) consume this budget. Authentication, quota, invalid request, content-filter, abort, unknown terminal states, and unsupported blocks fail closed without automatic retry.
  • Attempt scope: the initial call plus automatic retries form at most 6 report-level outer attempts, each dispatching at most one report stream. The plugin calls ctx.llm.stream directly and does not use DSH's agent request retry waterfall. This is a report-level budget, not a claim or guarantee of exactly six underlying HTTP requests.
  • State and recovery: the logical identity is period + report key. On read, the ledger normalizes each period in memory: all non-terminal entries remain in records, while only the last terminal entry by key order remains. Serialization prunes again before writing, recording pruned terminal keys in full terminalKeys (period → key → terminal code, sorted by key without a count limit). Tombstones contain only the terminal code, not attempts, observations, or usage; every known terminal key keeps a tombstone so historical keys cannot be automatically reopened. flatten and list/listDue/get project only records and do not expand tombstones into full entries. For a key with neither an entry nor a tombstone, automatic beginAttempt creates the initial entry. For an existing entry, the call must carry the matching cycleId; a waiting entry also must have reached nextRetryAt, while in-flight or terminal returns null. If records has no key but terminalKeys has a tombstone, beginAttempt also returns null. Manual Regenerate (force) is the path that clears that tombstone-only key's tombstone and starts a new cycle, resets the count, and snapshots current configuration; a failed force still does not advance lastRun. For example, if daily keys 2026-09-21 and 2026-09-23 are both terminal, records.daily retains only the full 2026-09-23 entry, while terminalKeys.daily records 2026-09-21: "auth-failed"; beginAttempt for the pruned 2026-09-21 returns null, and only beginForce removes the tombstone and starts a new cycle.
  • Cumulative cost: every actual report attempt records input/output/reasoning/total/cache-read/cache-write tokens and duration. Both ReportTokenUsage.reasoningTokens and retry usage use number | null. Cycle totals aggregate each field independently; if any attempt lacks a field, that total stays null rather than becoming 0. The settings status area shows the current outer attempt, cumulative cost, next retry, or terminal reason. Status responses without retry keep the previous UI.
  • Durability boundary: the retry ledger itself uses 0600 temporary files, complete writes, file fsync, atomic rename, and directory fsync where supported. Corrupt data is quarantined no-clobber for forensics and then handled fail-closed, never treated as an empty ledger. Existing report/index/lastRun writes are not all fsynced end-to-end, so this feature claims ledger durability and process-restart recovery, not whole-chain power-loss atomicity.

Capsule placement

The usage capsule (floating pill in the conversation corner) and its panel can be repositioned: Settings → Plugins → "用量统计" → "胶囊位置" — pick an anchor (top-right / top-left / bottom-right / bottom-left), offsets (horizontal / vertical / panel gap) and a z-index base, then hit Save. It takes effect immediately across all devices (persisted to ui.json and broadcast over SSE; no restart). Defaults: top-right / 0 / 48 / 10 / 40 — the capsule uses fixed positioning and never shifts with the scrolling conversation (no avoidance-related jitter; it stays put while scrolling); the horizontal 0 keeps the capsule's right edge flush with the container's right edge (right-aligned); the vertical 48 places the capsule just below the MCP-manager float (also top-right, 8px from the top) by default, so the two floats never overlap out of the box. The capsule and the MCP float do not probe or dodge each other — each is positioned solely by its own plugin config; the 10px panel gap keeps the panel tight under the capsule. The z-index base (default 40, matching the CSS default z-index: 40; the pill and the main panel opened on click both use this same config value) is clamped to 1-9000.

Mobile / tablet adaptation (issue #128): the breakpoint is decided from the conversation container's viewport width rather than a window media query — on narrow screens (<=480px, portrait phones / very narrow splits) the panel goes near full-width, cards reflow, and buttons get touch targets of about 44px; the tablet tier (<=834px) transitions; desktop is unchanged. Final capsule coordinates are clamped to the viewport in JS (safe-area semantics: the host has no viewport-fit=cover, so env(safe-area-inset-*) is always 0 and this degrades naturally to a plain clamp); the on-screen keyboard is followed via visualViewport resize, and orientation changes recompute on the next frame.

Cross-package avoidance contract (from issue #116, must not be reverted): this plugin's default offsetY: 48 relies on the dsh-mcp-manager float's default position (top-right, 8px from the top, ~26px tall) to sit right below it. Changing that default requires evaluating the MCP manager's default anchor / offsets in lockstep; reverting either side is a cross-package behavioral contract change.

Built-in DeepSeek official adapter (deepseek-official-builtin)

Claims provider deepseek-official and talks to the DeepSeek official "get user balance" endpoint GET https://api.deepseek.com/user/balance (see api-docs.deepseek.com/api/get-user-balance).

Data semantics

  • CNY only: when the official balance_infos[] array contains multiple currencies, only the currency === "CNY" entry is used; everything else (e.g. USD) is ignored. Amounts are strictly parsed from the official string fields (invalid/missing → null, NaN never persisted).
  • No CNY entry (USD-only or empty array) yields a normal frame with balance/toppedUp/grantedBalance = null (no throw); the capsule shows a "DeepSeek 余额 --" placeholder.
  • is_available=false marks an unavailable account: the frame is still recorded and its balance displayed, but it never participates in daily-consumption interval bookkeeping — adjacent intervals skip derivation and are labeled as excluded in the panel, so bans/balance wipes are not mistaken for consumption.

Daily consumption derivation (interval bookkeeping)

The official API has no usage endpoint, so daily consumption is derived by interval bookkeeping between consecutive samples. Field-semantics prerequisite (verified): topped_up_balance is the current remaining top-up account balance (identity total = toppedUp + granted holds; while consuming, toppedUp and total decrease together) — so no algebraic cancellation is applied; intervals are classified directly:

Adjacent sample interval Classification Handling
toppedUp unchanged and granted unchanged Pure consumption Consumption = balance drop, reconcilable with platform billing
toppedUp increased / granted changed Disturbed mixed Consumption under-counted; a "+¥X top-up" event is extracted and listed separately
Span > 27h (interruption) / either endpoint unavailable Skipped Excluded from bars and totals; noted in the panel
  • Interval attribution: booked to the day of the interval's end — overnight consumption across midnight is never lost; any single complete interval in a day yields output (cold start works naturally, no cross-day baseline dependency).
  • Presentation layering: the daily bar = sum of booked interval drops for that day; top-up totals are shown separately in green ("+¥X recharged" beside the summary), never mixed with consumption; card 1's badge uses the same caliber (sum of pure consumption intervals over 24h).
  • Axis break (B2): at each top-up the line chart breaks its axis — points after the top-up shift down by the accumulated amount to remove the step; a dashed connector links both real water levels and labels the amount, keeping the jump explicit.

Two-card panel and peak/valley badge

  • Card 1: CNY balance headline + consumption badge (interval-bookkeeping caliber, no false ▲ on top-ups) + 24h fluctuation line chart (unified time anchor, downsampling ≤300 points, axis breaks at each top-up).
  • Card 2: daily consumption bar chart over the last 15 calendar days (interval bookkeeping; blue bars up for consumption, green bars down for net gains, anomalies labeled only; top-up amounts listed separately in bar titles and the summary row).
  • The capsule always carries a peak/valley countdown badge (pure local-time computation, independent of remote data — rendered even on fetch failures):
    • Windows are fixed UTC weekday windows 01:00–04:00 / 06:00–10:00 (half-open intervals), per api-docs.deepseek.com/quick_start/pricing, verified on 2026-08-26; hard-coded constants with no config knob, weekends are off-peak all day.
    • Off-peak shows the countdown to the next peak start (e.g. ⚡谷 · 距峰 02:41); peak shows the countdown to the next valley switch; the tooltip documents the UTC window definition plus a server-timezone hint.

API key resolution order (provider config chain)

  1. Plugin config apiKey (explicit)
  2. Environment variable {PROVIDER}_API_KEY (uppercase, hyphens → underscores)
  3. opencode-go legacy env var OPENCODE_GO_API_KEY
  4. {PROVIDER}_API_KEY in <DSH_HOME>/.credentials.yaml (opencode-go also probes the legacy name OPENCODE_GO_API_KEY)
  5. opencode-go compatibility: ~/.local/share/opencode/auth.json entry opencode-go (or opencode)

DeepSeek official adapter three-level key chain: plugin config apiKey injection → credential-chain derived env (provider deepseek-official → DEEPSEEK_OFFICIAL_API_KEY) → adapter-local DEEPSEEK_API_KEY self-check fallback (shared with the llm layer, covering "llm works but balance API 401"; this fallback is an adapter implementation detail, not a shared provider-config special case). With all three absent, fetching reports no-api-key and degrades to a stale frame (the peak/valley badge still renders).

Routes (all loopback-fenced)

Route Description
GET /api/dsh-provider-usage/stats?provider=X Usage stats + capsuleHtml + status/adapterVersion
GET /api/dsh-provider-usage/history?provider=X&days=N History query + panelHtml + query range (in-process render cache, see below)
GET /api/dsh-provider-usage/health Health check + adapter snapshot + error log
GET /api/dsh-provider-usage/adapters.json Adapter candidate metadata (same source as the settings page list, incl. modelProviders)
POST /api/dsh-provider-usage/adapters/select Switch / clear the enabled adapter
POST /api/dsh-provider-usage/adapters/inspect Preview an adapter file (echo exports, no registration)
POST /api/dsh-provider-usage/adapters/add Register a user adapter file (settings page flow)

Trend directory dimension (data semantics)

The directory dimension on the trend panel answers "which working directory did the usage go to". Attribution comes from the official SessionHeader.cwd creation metadata and is normalized to a basename by sanitizeDirName before it is stored (C0/C1 control characters stripped; POSIX / and Windows \ separators both honored; root/empty → unidentified bucket).

  • Unidentified bucket ((unidentified)): the session has no cwd, attribution failed, or that day's data predates the directory dimension (legacy shards carry no directory information). The bucket is never silently dropped — UI and reports render it as-is.
  • Totals are conserved: with healthy data, the per-day total of the directory face equals the provider face. The directory face = recorded directory day-buckets + a per-day residual (aggregate face − directory face, attributed to the unidentified bucket) — the residual is "that day's data without directory information", so historical usage neither disappears from the chart nor gets counted twice. A negative residual (directory face larger than the aggregate face) indicates corrupted shard data: it is clamped to 0 and the identity no longer holds (the main source is already blocked by the detail-shard read whitelist).
  • Read-side projection only: the residual is computed at query time; shard files are never rewritten and existing data is never mutated.
  • Irrecoverable boundary: attribution is fixed when a session is first recorded and cannot be reconstructed from legacy shards, so pre-upgrade data stays in the unidentified bucket; only newly recorded usage can carry a real directory name.

/history render cache

panelHtml served by /history is cached in the host process (issue #105, sub-item 1); repeated requests skip recomputation while data is unchanged:

  • Hit condition: within the same process, (provider, enabled adapter, normalized query window) all unchanged. The window is normalized at calendar-day granularity — the per-request drift of end=Date.now() never enters the cache key; repeated requests within the same calendar day hit the same entry. Different days values normalize to different entries and never cross-contaminate. Hits replay a snapshot of the return-value string layer ({panelHtml, error, at}) only — entries are never cached; the response range still echoes the request's real start/end.
  • Invalidation (four points): primary = full clear on successful history append (new data landed, all panel entries dropped at once); plus three sync hooks — select (switch/clear enabled adapter), add (register new adapter), and hot reload (adapter file changed and reloaded).
  • Fallback TTL: compile-time constant 90000ms (90s, mid-point of the bounded [60s, 120s] range), not a config key; it is only a fallback, not the primary mechanism. In production the hit ratio is gated by warmup period (5min) × stats cache TTL (60s): client polls between two appends all hit.
  • Not cached: pipeline errors and no-adapter / no-enabled-adapter structured responses are never cached — once the condition clears, the next request recomputes immediately and may succeed instead of replaying a stale error or placeholder.
  • No conditional-request negotiation: no ETag / Last-Modified headers, no 304 short-circuit; clients keep cache: "no-store" — this cache is purely server-side with zero client changes.

Adapter development guide (v2 contract)

Any data source plugs in with one mjs file (reference implementations: built-in adapter sources src/adapters/opencode-go.mjs, src/adapters/deepseek-official.mjs and src/adapters/zai-coding-cn.mjs; the host injects a shared chart-tools utils object into fetchData/formatPanel inputs, see docs/adapter-guide.md §3.3; agent-oriented walkthrough: docs/adapter-guide.md):

// my-stats.mjs
export const version = 2;                    // required: contract version (fixed 2)
export const name = "my-stats";              // required: unique name (^[A-Za-z0-9_-]{2,64}$)
export const label = "My Stats";             // optional: display name
export const providers = ["my-relay"];       // required: claimed providers

/** Required: fetch raw data (runs on the host; args injected by the plugin) */
export async function fetchData({ apiEndpoint, staticPath, apiKey, signal, timeoutMs }) {
  const res = await fetch(apiEndpoint + staticPath, {
    headers: { Authorization: `Bearer ${apiKey}` },
    signal,
  });
  if (!res.ok) throw new Error(`http-${res.status}`);
  return res.json(); // return only the minimal dataset needed for display
}

/** Required: capsule content (host side, returns an HTML string) */
export function formatCapsule({ data, status, esc }) {
  return `<span style="font-weight:600">${esc(data.visits ?? 0)} visits</span>`;
}

/** Required: panel content (host side, returns an HTML string)
 *  Input also carries an optional shared chart-tools `utils` object: use
 *  `const U = utils || {}` then call `U.miniAreaSvg({...})` for SVG charts
 *  (see docs/adapter-guide.md §3.3). */
export function formatPanel({ entries, range, truncated, esc, utils }) {
  const U = utils || {};
  const rows = entries.slice(-60).map((e) =>
    `<tr><td>${esc(new Date(e.time).toLocaleString())}</td><td>${esc(e.data.visits)}</td></tr>`).join("");
  return `<table>${rows}</table>`;
}

Wiring — recommended via the settings page (no config-file editing, no restart):

  1. Open dsh Settings → Plugins → "Usage Stats"
  2. Under the target provider click "+ Add adapter", enter the mjs file path (~ expansion / absolute paths supported)
  3. Click "Inspect" to echo the exports → confirm add (auto-persisted and hot-registered as the provider's enabled adapter)
  4. Toggle candidates on/off; changes take effect immediately and persist

Alternatively declare in cordis.patch.yml (optional overlay, loaded at startup):

plugins:
  '@wingsky-1/dsh-provider-usage':
    adapter: ~/dsh/my-stats.mjs
    provider: my-relay
    staticPath: /api/usage
    # autoReload is enabled by default; to disable (security/stability concerns):
    # autoReload: false

Load failures are rejected fail-fast and logged (visible in the settings panel) without affecting other plugin features. Path safety: relative paths must land inside DSH_HOME or the plugin home; non-normalized forms (../ traversal) are rejected with 400.

v1 legacy contract removed

The v1 legacy contract was removed by breaking change #932; only the v2 contract is supported (fetchData returns raw objects, formatCapsule/ formatPanel return HTML rendered host-side, name allowlist-validated, see docs/adapter-guide.md). The settings "Usage Stats" page carries detect/add/switch/disable (auto-persisted); cordis.patch.yml declarations are an optional overlay only.

Security model

  • Adapter code = full Node permissions on the host (network/fs/env), equivalent to writing your own in-process plugin; only load local files you trust — the plugin never pulls code from the network for execution
  • Keys never reach the browser: apiKey lives only in host-process memory and is injected into fetchData as an argument; adapter files never contain key values (equally true for the built-in DeepSeek official adapter: three-level key chain above, no key substring in stats/history/adapters responses nor in capsule/panel HTML)
  • Two-layer XSS defense: external API strings must be escaped with esc() before being placed into HTML (documented obligation); the plugin additionally sanitizes all format output on the host (script/iframe/on* attributes/javascript: removal). The sanitizer also closes HTML entity-encoded variants — named / decimal / hex, with or without trailing semicolons are decoded before matching, and protocol-like payloads are located after stripping tab/LF/CR per WHATWG URL semantics (the jav ascript: family included); sanitization only removes and iterates under a generous iteration bound with fail-closed empty output beyond it (bounding worst-case CPU cost) — the decoded view is used solely for locating matches and never written back, so legitimately escaped text passes through untouched
  • Hot reload is on by default (autoReload, can be disabled explicitly); when enabled it polls mtime+size, atomically swaps in the new version only after validation passes, and keeps the old version on failure
  • Timeout discipline (two layers, do not conflate):
    • Server-side data-fetch hop: fetchData gets a forced 5s timeout (fixed, not configurable); on timeout the host actively aborts the real fetch — the signal handed to fetchData is a merged signal (timeout guard × external cancellation, composed via manual cascading listeners for node>=20 compatibility). Adapters should pass it through to the underlying fetch's RequestInit.signal so the request is truly interrupted on timeout/cancellation instead of leaving a dangling socket; without pass-through the timeout only gives up waiting and the request may still complete in the background.
    • Browser-to-dsh-web hop (#268): every client HTTP request goes through the fetchTimeout wrapper with a default 10s AbortSignal.timeout guard (aligned with dsh-mcp-manager api(), #111) — when a backgrounded mobile device has its TCP silently dropped (half-open connection), requests issued on the dead connection can hang until TCP retransmission timeouts (up to 15 minutes); this guard bounds client-side waiting so the page never stalls. 10s exceeds the server-side fetch cap (5s), so normal requests are never killed prematurely. When the caller supplies its own signal, the guard is not applied (avoids competing cancellations). Zero-argument fetchData that ignores the signal keeps working; fetching uses per-provider locks and concurrent requests on the same provider queue up and reuse the first result — page requests are never blocked
  • Report generation (#503 M3; #532 annual-report style): zero credentials, zero new network egress — model calls go through the host llm service (ctx.llm.stream), credentials held by existing dsh provider config; generation emits no session events and is excluded from usage stats (consumption tracked in report metadata); config/artifacts/lastRun live under historyRoot/reports/ (0600); bodies pass the escape-then-transform pipeline (escape first, then introduce attribute-less h3/strong/ul/li/p whitelist tags) plus sanitizeHtml double sanitization before rendering; the injected stats JSON only carries aggregate numbers (no session details or paths); per-period prompt templates (prompts{daily,weekly,monthly}, auto-migrated from the legacy single template); empty windows skip the model; optional notifier push is off by default and carries no project paths; manual generation is asynchronous (#625): POST returns 202+taskId immediately and the client polls status, decoupled from LLM latency (no longer subject to the 10s fetch timeout); manual generation is idempotent by default (#626) — an existing successful report for the window is reused, and "Regenerate" forces an overwrite. Per-attempt structured logs contain only attempt/result/stable code, the opaque effort ID, input/output/reasoning/total/cache token numbers, and durationMs. They never record prompts, reasoning text, API keys/credentials, or paths; reasoning is observed only as a numeric token count. Report history is deduplicated per window by a read-side projection (one row per window = newest version; index.jsonl stays append-only); lastRun is derived/calibrated from the index facts (schema v2, #624: legacy "same-day" windows are recognized as not-closed and rolled back, so a 06:00 Monday no longer drops the daily report)
  • Fail-fast loading: missing exports / wrong types / invalid names are rejected with diagnosable errors
  • History: daily-sharded JSONL (0600 permissions) with automatic age/size pruning; raw data is serialization-checked before persistence
  • All routes loopback-fenced (403 non-loopback / 405 wrong method); minimal information disclosure on admin endpoints (user files shown as basename only); request rate is bounded (polling ≤1/60s + warmup ≤1/5min + 60s TTL cache)

Verify

# Health check (loopback)
curl -s http://127.0.0.1:3080/api/dsh-provider-usage/health

# Source lives in src/, rebuild after changes
pnpm --filter @wingsky-1/dsh-provider-usage build
pnpm --filter @wingsky-1/dsh-provider-usage test

License

MIT

Content from the project README on GitHub ↗

Comments

Comments live in GitHub Discussions. Sign in with GitHub to post or react.