@linxin666/dsh-usage 0.4.3 → 0.4.5

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.
@@ -20,7 +20,7 @@ import type { BalanceParse, PlanParse } from '../core/adapters.ts'
20
20
  import { foldAliasRoutes } from '../core/provider-routes.ts'
21
21
  import { deepseekModelSpend } from '../core/pricing.ts'
22
22
  import { createLedgerDocument, deserializeLedger, foldUsage, ledgerDayKeys, localDateKey, pruneLedger, summarizeDays, totalTokens } from '../core/ledger.ts'
23
- import type { BalanceView, CredentialKind, ObservedSpendView, PlanView, ProviderSnapshotState, ProviderSnapshotView, UsageLedgerDocument, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
23
+ import type { BalanceView, CredentialKind, ObservedSpendView, PlanView, ProviderSnapshotState, ProviderSnapshotView, UsageDayView, UsageLedgerDocument, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
24
24
  import { addTotals, emptyTotals } from '../core/types.ts'
25
25
 
26
26
  /**
@@ -103,19 +103,129 @@ async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
103
103
  }
104
104
  }
105
105
 
106
+ function unwrapVolatiles(value: unknown): unknown {
107
+ if (value === null || value === undefined) return value
108
+ if (typeof value === 'object') {
109
+ if (typeof (value as { get?: unknown }).get === 'function') {
110
+ return unwrapVolatiles((value as { get(): unknown }).get())
111
+ }
112
+ if (Array.isArray(value)) {
113
+ return value.map(unwrapVolatiles)
114
+ }
115
+ const result: Record<string, unknown> = {}
116
+ for (const [k, v] of Object.entries(value)) {
117
+ result[k] = unwrapVolatiles(v)
118
+ }
119
+ return result
120
+ }
121
+ return value
122
+ }
123
+
124
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
125
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
126
+ }
127
+
128
+ function deepMerge(base: unknown, patch: unknown): unknown {
129
+ if (!isPlainObject(base) || !isPlainObject(patch)) return patch !== undefined ? patch : base
130
+ const result: Record<string, unknown> = { ...base }
131
+ for (const [key, value] of Object.entries(patch)) {
132
+ if (value === undefined) continue
133
+ result[key] = isPlainObject(value) && isPlainObject(result[key]) ? deepMerge(result[key], value) : value
134
+ }
135
+ return result
136
+ }
137
+
106
138
  /**
107
139
  * Read a foreign settings namespace's resolved value (the llm adapter
108
- * profiles, the agent default model). Unregistered namespaces read as
109
- * undefined; nothing here throws into the poll loop.
140
+ * profiles, the agent default model). Resolves across pre-0.1.7 settings.get(),
141
+ * 0.1.7 SettingsForms.describe(), live loader entries, and configEditor.
142
+ * Unregistered namespaces read as undefined; nothing here throws into the poll loop.
110
143
  */
111
144
  function readNamespace(ctx: Context, ns: string): unknown {
112
145
  try {
113
- const settings = service<{ get(ns: unknown): unknown }>(ctx, 'settings')
114
- if (settings === undefined) return undefined
115
- return settings.get(ns)
146
+ // 1. Try legacy settings.get(ns) or 0.1.7 settings.describe()
147
+ const settings = service<{
148
+ get?(ns: unknown): unknown
149
+ describe?(options?: unknown): Array<{ ns: string; value: unknown }>
150
+ }>(ctx, 'settings')
151
+ if (settings !== undefined) {
152
+ if (typeof settings.get === 'function') {
153
+ const value = settings.get(ns)
154
+ if (value !== undefined) return unwrapVolatiles(value)
155
+ }
156
+ if (typeof settings.describe === 'function') {
157
+ const descriptors = settings.describe({ redactSecrets: false })
158
+ if (Array.isArray(descriptors)) {
159
+ const match = descriptors.find((d) =>
160
+ d.ns === ns
161
+ || (ns === 'llm-pi-ai' && (d.ns === 'llm' || isPlainObject((d.value as { providers?: unknown })?.providers)))
162
+ || (ns === 'llm-deepseek' && (d.ns === 'llm-deepseek' || d.ns === 'llm-deepseek-api-key'))
163
+ )
164
+ if (match !== undefined && match.value !== undefined) {
165
+ return unwrapVolatiles(match.value)
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ // 2. Try ctx.root.loader or ctx.loader entries
172
+ const loader = (ctx as unknown as {
173
+ root?: { loader?: { entries(): Iterable<{ options: { id: string; name?: string }; fiber?: { config?: unknown } }> } }
174
+ loader?: { entries(): Iterable<{ options: { id: string; name?: string }; fiber?: { config?: unknown } }> }
175
+ }).root?.loader
176
+ ?? (ctx as unknown as {
177
+ loader?: { entries(): Iterable<{ options: { id: string; name?: string }; fiber?: { config?: unknown } }> }
178
+ }).loader
179
+ if (loader !== undefined && typeof loader.entries === 'function') {
180
+ for (const entry of loader.entries()) {
181
+ const id = entry.options?.id
182
+ const name = entry.options?.name
183
+ if (
184
+ id === ns
185
+ || name === `@deepseek-ai/dsh-${ns}`
186
+ || name?.endsWith(`/${ns}`)
187
+ || (ns === 'llm-pi-ai' && (id === 'llm' || name === '@deepseek-ai/dsh-llm-pi-ai'))
188
+ || (ns === 'llm-deepseek' && (id === 'llm-deepseek-api-key' || name === '@deepseek-ai/dsh-llm-deepseek-api-key'))
189
+ ) {
190
+ if (entry.fiber?.config !== undefined) {
191
+ return unwrapVolatiles(entry.fiber.config)
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ // 3. Try configEditor service
198
+ const editor = service<{
199
+ configuration?(): Array<{
200
+ entry: { options: { id: string; name?: string }; fiber?: { config?: unknown } }
201
+ inherited: Record<string, unknown>
202
+ override: Record<string, unknown>
203
+ }>
204
+ }>(ctx, 'configEditor')
205
+ if (editor !== undefined && typeof editor.configuration === 'function') {
206
+ const rows = editor.configuration()
207
+ const match = rows.find((r) => {
208
+ const id = r.entry.options.id
209
+ const name = r.entry.options.name
210
+ return (
211
+ id === ns
212
+ || name === `@deepseek-ai/dsh-${ns}`
213
+ || name?.endsWith(`/${ns}`)
214
+ || (ns === 'llm-pi-ai' && (id === 'llm' || name === '@deepseek-ai/dsh-llm-pi-ai'))
215
+ || (ns === 'llm-deepseek' && (id === 'llm-deepseek-api-key' || name === '@deepseek-ai/dsh-llm-deepseek-api-key'))
216
+ )
217
+ })
218
+ if (match !== undefined) {
219
+ if (match.entry.fiber?.config !== undefined) {
220
+ return unwrapVolatiles(match.entry.fiber.config)
221
+ }
222
+ return unwrapVolatiles(deepMerge(match.inherited, match.override))
223
+ }
224
+ }
116
225
  } catch {
117
226
  return undefined
118
227
  }
228
+ return undefined
119
229
  }
120
230
 
121
231
  export class UsageService {
@@ -206,10 +316,10 @@ export class UsageService {
206
316
  // THIS route's account: it is supported by the adapter but not
207
317
  // applicable here, and the UI renders it as unsupported rather than
208
318
  // showing the other account's number (issue #1688).
209
- ...(adapter?.balance !== undefined && balanceAppliesToRoute(adapter, this.piAiProfile(route.id)?.baseURL)
319
+ ...(adapter?.balance !== undefined && balanceAppliesToRoute(adapter, this.routeBaseUrl(route.id))
210
320
  ? { balanceSupported: true }
211
321
  : {}),
212
- ...(adapter?.balance !== undefined && !balanceAppliesToRoute(adapter, this.piAiProfile(route.id)?.baseURL)
322
+ ...(adapter?.balance !== undefined && !balanceAppliesToRoute(adapter, this.routeBaseUrl(route.id))
213
323
  ? { balanceSupported: false }
214
324
  : {}),
215
325
  ...(adapter?.plan !== undefined ? { planSupported: true } : {}),
@@ -240,6 +350,9 @@ export class UsageService {
240
350
  totals: range.totals,
241
351
  providers: range.providers,
242
352
  },
353
+ // Every retained day, not just the trend window: the day picker's
354
+ // option list reaches back to the oldest day retention still keeps.
355
+ availableDays: allKeys,
243
356
  all: {
244
357
  from: allKeys[0] ?? todayKey,
245
358
  to: allKeys[allKeys.length - 1] ?? todayKey,
@@ -283,8 +396,19 @@ export class UsageService {
283
396
  }
284
397
  }
285
398
 
399
+ /**
400
+ * One retained local day aggregated per provider and model, as the day
401
+ * route serves it. A day the ledger never held — pruned by retention, or
402
+ * simply without usage — reads as a zeroed day rather than an error, so a
403
+ * picker selection that aged out degrades to "no usage" instead of a broken
404
+ * panel. The route gates the key's shape before it gets here.
405
+ */
406
+ day(dateKey: string): UsageDayView {
407
+ return this.daySummary(dateKey)
408
+ }
409
+
286
410
  /** One local day aggregated per provider. */
287
- private daySummary(dateKey: string): UsageOverviewView['usage']['today'] {
411
+ private daySummary(dateKey: string): UsageDayView {
288
412
  const day = this.ledger.days[dateKey] ?? {}
289
413
  const { totals, providers } = summarizeDays([day])
290
414
  return { date: dateKey, totals, providers }
@@ -587,7 +711,7 @@ export class UsageService {
587
711
  // account, and probing the official endpoint would print this account's
588
712
  // money under that route's name (issue #1688). Such a route reports the
589
713
  // balance as unsupported instead.
590
- const balanceApplies = balanceAppliesToRoute(adapter, this.piAiProfile(route.id)?.baseURL)
714
+ const balanceApplies = balanceAppliesToRoute(adapter, this.routeBaseUrl(route.id))
591
715
  if (credential.key === undefined) {
592
716
  // Nothing to probe with (oauth grant, no credential): retained facts
593
717
  // would never refresh again, so the row resets to its credential
@@ -705,4 +829,15 @@ export class UsageService {
705
829
  return section?.apiKeyEnv ?? 'DEEPSEEK_API_KEY'
706
830
  }
707
831
 
832
+ /** The DeepSeek official adapter's configured baseURL. */
833
+ private deepseekBaseUrl(): string | undefined {
834
+ const section = readNamespace(this.ctx, 'llm-deepseek') as { baseURL?: string } | undefined
835
+ return section?.baseURL
836
+ }
837
+
838
+ /** The effective base URL for a provider route (pi-ai or deepseek). */
839
+ private routeBaseUrl(provider: string): string | undefined {
840
+ return this.piAiProfile(provider)?.baseURL ?? (isDeepSeekProviderRoute(provider) ? this.deepseekBaseUrl() : undefined)
841
+ }
842
+
708
843
  }
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
4
4
  import z from '@deepseek-ai/schemastery'
5
5
  import { mountOnce } from './mount-once.ts'
6
6
  import { UsageService, type UsageServiceOptions } from './host/usage-service.ts'
7
- import { makeUsageOverviewRoute, makeUsageRefreshRoute } from './host/routes.ts'
7
+ import { makeUsageDayRoute, makeUsageOverviewRoute, makeUsageRefreshRoute } from './host/routes.ts'
8
8
 
9
9
  declare module '@deepseek-ai/cordis' {
10
10
  interface Events {
@@ -126,9 +126,9 @@ export const apply = mountOnce('@linxin666/dsh-usage', (ctx: Context, config?: U
126
126
  /** True while this activation has a start queued behind a predecessor's flush. */
127
127
  let queued = false
128
128
 
129
- /** Register the service's two routes; the returned disposer unregisters them. */
129
+ /** Register the service's routes; the returned disposer unregisters them. */
130
130
  const mountRoutes = (next: UsageService): (() => void) => {
131
- const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageRefreshRoute(ctx, next)]
131
+ const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageDayRoute(ctx, next), makeUsageRefreshRoute(ctx, next)]
132
132
  .map((route) => ctx.webServer.register(route))
133
133
  return () => {
134
134
  for (const dispose of disposers) {
@@ -1,39 +0,0 @@
1
- /**
2
- * Async-boundary guard shared by the plugin family's Host halves: every
3
- * fire-and-forget promise chain and callback the host runtime does not own
4
- * (route handlers, timers, event listeners, spawned work) funnels through
5
- * these helpers. The dsh host installs a process-level fail-loud guard that
6
- * turns ANY unhandled promise rejection into a whole-process exit — one
7
- * plugin's stray rejection would otherwise take every plugin down. These
8
- * helpers exist so that failure mode is structurally impossible in family
9
- * code: the rejection becomes a logged error at the plugin boundary instead.
10
- *
11
- * Complements the aggregate's shell isolation (packages/dsh-web-all): the
12
- * shell contains import/activation failures at boot; runGuarded contains
13
- * run-time failures after activation.
14
- * @module dsh-web-shared/host/run-guarded
15
- */
16
- /**
17
- * Run one async operation, logging (never propagating) a rejection. Use for
18
- * promises whose lifecycle the caller does not await: spawned work, retry
19
- * loops, background flushes. Returns the original promise so callers can
20
- * still chain when they want to.
21
- * @param promise - the work to shield; any thenable.
22
- * @param label - log prefix naming the work site.
23
- * @param log - error sink; defaults to console.error.
24
- * @returns the input promise (rejection already consumed).
25
- */
26
- export declare function runGuarded<T>(promise: PromiseLike<T>, label: string, log?: (error: unknown) => void): PromiseLike<T>;
27
- /**
28
- * Wrap one callback so every invocation is individually guarded: a rejection
29
- * inside one call is logged and swallowed instead of escaping into whatever
30
- * infrastructure invoked the callback (HTTP server, EventEmitter, interval).
31
- * Sync throws are caught identically; a returned promise is replaced by
32
- * `undefined` after guarding (callers that need the original rejection should
33
- * await inside their own try/catch instead).
34
- * @param label - log prefix naming the callback site.
35
- * @param handler - the work to guard.
36
- * @param log - error sink; defaults to console.error.
37
- * @returns a wrapped callback with the same parameter list.
38
- */
39
- export declare function guardedHandler<A extends unknown[]>(label: string, handler: (...args: A) => unknown, log?: (error: unknown) => void): (...args: A) => unknown;
@@ -1,76 +0,0 @@
1
- // Generated by scripts/sync-shared.mjs from shared/host/run-guarded.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
- /**
3
- * Async-boundary guard shared by the plugin family's Host halves: every
4
- * fire-and-forget promise chain and callback the host runtime does not own
5
- * (route handlers, timers, event listeners, spawned work) funnels through
6
- * these helpers. The dsh host installs a process-level fail-loud guard that
7
- * turns ANY unhandled promise rejection into a whole-process exit — one
8
- * plugin's stray rejection would otherwise take every plugin down. These
9
- * helpers exist so that failure mode is structurally impossible in family
10
- * code: the rejection becomes a logged error at the plugin boundary instead.
11
- *
12
- * Complements the aggregate's shell isolation (packages/dsh-web-all): the
13
- * shell contains import/activation failures at boot; runGuarded contains
14
- * run-time failures after activation.
15
- * @module dsh-web-shared/host/run-guarded
16
- */
17
-
18
- /** Format one failure line for logging. */
19
- function formatFailure(label: string, error: unknown): Error {
20
- return new Error(`[${label}] unhandled async failure: ${error instanceof Error ? error.stack ?? error.message : String(error)}`)
21
- }
22
-
23
- /**
24
- * Run one async operation, logging (never propagating) a rejection. Use for
25
- * promises whose lifecycle the caller does not await: spawned work, retry
26
- * loops, background flushes. Returns the original promise so callers can
27
- * still chain when they want to.
28
- * @param promise - the work to shield; any thenable.
29
- * @param label - log prefix naming the work site.
30
- * @param log - error sink; defaults to console.error.
31
- * @returns the input promise (rejection already consumed).
32
- */
33
- export function runGuarded<T>(promise: PromiseLike<T>, label: string, log: (error: unknown) => void = console.error): PromiseLike<T> {
34
- void Promise.resolve(promise).catch(error => {
35
- log(formatFailure(label, error))
36
- })
37
- return promise
38
- }
39
-
40
- /**
41
- * Wrap one callback so every invocation is individually guarded: a rejection
42
- * inside one call is logged and swallowed instead of escaping into whatever
43
- * infrastructure invoked the callback (HTTP server, EventEmitter, interval).
44
- * Sync throws are caught identically; a returned promise is replaced by
45
- * `undefined` after guarding (callers that need the original rejection should
46
- * await inside their own try/catch instead).
47
- * @param label - log prefix naming the callback site.
48
- * @param handler - the work to guard.
49
- * @param log - error sink; defaults to console.error.
50
- * @returns a wrapped callback with the same parameter list.
51
- */
52
- export function guardedHandler<A extends unknown[]>(
53
- label: string,
54
- handler: (...args: A) => unknown,
55
- log: (error: unknown) => void = console.error,
56
- ): (...args: A) => unknown {
57
- return (...args: A) => {
58
- try {
59
- const result = handler(...args)
60
- if (isPromiseLike(result)) {
61
- void Promise.resolve(result).catch(error => {
62
- log(formatFailure(label, error))
63
- })
64
- return undefined
65
- }
66
- return result
67
- } catch (error) {
68
- log(formatFailure(label, error))
69
- return undefined
70
- }
71
- }
72
- }
73
-
74
- function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
75
- return typeof value === 'object' && value !== null && typeof (value as PromiseLike<unknown>).then === 'function'
76
- }