@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.
- package/README.i18n.yaml +2 -2
- package/README.md +21 -5
- package/README.zh.md +21 -5
- package/lib/client.js +466 -32
- package/lib/client.js.map +1 -1
- package/lib/index.js +434 -23
- package/lib/types/client/UsageSectionCard.d.ts +7 -0
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/core/adapters.d.ts +28 -2
- package/lib/types/core/holidays.d.ts +25 -0
- package/lib/types/core/ledger.d.ts +8 -0
- package/lib/types/core/pricing.d.ts +34 -6
- package/lib/types/core/types.d.ts +26 -5
- package/lib/types/host/routes.d.ts +11 -0
- package/lib/types/host/usage-service.d.ts +13 -1
- package/package.json +14 -14
- package/src/client/UsageSectionCard.tsx +174 -26
- package/src/client/index.ts +10 -2
- package/src/client/locales.ts +16 -0
- package/src/client/usage.module.css +47 -0
- package/src/core/adapters.ts +34 -3
- package/src/core/holidays.ts +248 -0
- package/src/core/ledger.ts +14 -5
- package/src/core/pricing.ts +75 -15
- package/src/core/types.ts +28 -1
- package/src/host/routes.ts +31 -0
- package/src/host/usage-service.ts +145 -10
- package/src/index.ts +3 -3
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/host/run-guarded.ts +0 -76
|
@@ -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).
|
|
109
|
-
*
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
319
|
+
...(adapter?.balance !== undefined && balanceAppliesToRoute(adapter, this.routeBaseUrl(route.id))
|
|
210
320
|
? { balanceSupported: true }
|
|
211
321
|
: {}),
|
|
212
|
-
...(adapter?.balance !== undefined && !balanceAppliesToRoute(adapter, this.
|
|
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):
|
|
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.
|
|
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
|
|
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;
|
package/src/host/run-guarded.ts
DELETED
|
@@ -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
|
-
}
|