@linxin666/dsh-usage 0.3.23 → 0.4.0
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 +7 -9
- package/README.zh.md +7 -9
- package/icon.svg +3 -0
- package/lib/client.js +621 -65
- package/lib/client.js.map +1 -1
- package/lib/index.js +258 -271
- package/lib/types/client/UsageFootCard.d.ts +55 -0
- package/lib/types/client/UsageSectionCard.d.ts +3 -4
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/foot-card-mount.d.ts +34 -0
- package/lib/types/client/index.d.ts +21 -8
- package/lib/types/client/locales.d.ts +6 -4
- package/lib/types/core/provider-routes.d.ts +26 -0
- package/lib/types/core/types.d.ts +14 -1
- package/lib/types/host/access.d.ts +17 -0
- package/lib/types/host/http.d.ts +18 -1
- package/lib/types/host/pair-access.d.ts +34 -0
- package/lib/types/host/routes.d.ts +16 -7
- package/lib/types/host/usage-service.d.ts +32 -76
- package/lib/types/index.d.ts +14 -2
- package/package.json +20 -17
- package/src/client/UsageFootCard.tsx +300 -0
- package/src/client/UsageSectionCard.tsx +41 -23
- package/src/client/body-mutations.ts +133 -0
- package/src/client/foot-card-mount.tsx +112 -0
- package/src/client/index.ts +57 -16
- package/src/client/locales.ts +12 -8
- package/src/client/usage.module.css +188 -0
- package/src/core/adapters.ts +40 -6
- package/src/core/provider-routes.ts +37 -0
- package/src/core/types.ts +14 -1
- package/src/host/access.ts +21 -0
- package/src/host/http.ts +23 -1
- package/src/host/pair-access.ts +49 -0
- package/src/host/routes.ts +19 -10
- package/src/host/usage-service.ts +74 -218
- package/src/index.ts +59 -60
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar foot card: the compact usage glance seated below the shell sidebar's
|
|
3
|
+
* Settings row. It is deliberately a GLANCE, not the panel the 2026-09-18
|
|
4
|
+
* removal took out. Two states, persisted across reloads:
|
|
5
|
+
*
|
|
6
|
+
* - expanded (default): a price-first headline (today's estimated spend,
|
|
7
|
+
* falling back to today's tokens when nothing priced was recorded), a
|
|
8
|
+
* tokens/calls line, up to two configured-provider balances, and an
|
|
9
|
+
* updated-at footer;
|
|
10
|
+
* - collapsed: a one-line strip with the gauge glyph, the spending
|
|
11
|
+
* provider's name, the label and the headline value.
|
|
12
|
+
*
|
|
13
|
+
* The card body is one button that opens the settings panel on the usage
|
|
14
|
+
* section, so detail lives in exactly one place; the corner chevron toggles
|
|
15
|
+
* the collapse state (sibling buttons — buttons inside a button are invalid
|
|
16
|
+
* HTML).
|
|
17
|
+
*
|
|
18
|
+
* Data comes from the same shared store the settings section reads; the card
|
|
19
|
+
* runs its own relaxed poll loop (30 s, visible-tab only) because the sidebar
|
|
20
|
+
* foot is permanently mounted. The card disappears while the plugin is
|
|
21
|
+
* disabled (settings flag) or while the host serves no usage routes (a 404
|
|
22
|
+
* means the host half is off), matching the section's own gating.
|
|
23
|
+
* @module @linxin666/dsh-usage/client/UsageFootCard
|
|
24
|
+
*/
|
|
25
|
+
import { type ReactNode } from 'react';
|
|
26
|
+
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
27
|
+
import { type UsageSettings } from './UsageSectionCard.tsx';
|
|
28
|
+
import type { UsageStoreInstance } from './usage-store.ts';
|
|
29
|
+
/** Props the mount forwards into the React tree. */
|
|
30
|
+
export interface UsageFootCardProps {
|
|
31
|
+
/** The shared overview store (section and card read the same snapshot). */
|
|
32
|
+
store: UsageStoreInstance;
|
|
33
|
+
/** Fetch one overview now. */
|
|
34
|
+
poll: () => void;
|
|
35
|
+
/** Card-body click: open the settings panel on the usage section. */
|
|
36
|
+
onOpen: () => void;
|
|
37
|
+
/** The shared settings form; the card hides while the plugin is disabled. */
|
|
38
|
+
settings: ConfigForm<UsageSettings>;
|
|
39
|
+
/** Locale-change source; the plain copy re-renders on a language switch. */
|
|
40
|
+
locale?: {
|
|
41
|
+
subscribe(listener: () => void): () => void;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Poll cadence of the permanently seated card; the section's 10 s loop stays its own. */
|
|
45
|
+
export declare const FOOT_CARD_POLL_MS = 30000;
|
|
46
|
+
/** localStorage key holding the collapsed flag ('1' = collapsed strip). */
|
|
47
|
+
export declare const FOOT_CARD_COLLAPSED_KEY = "dsh-usage.foot-card.collapsed";
|
|
48
|
+
/** Read the persisted collapsed flag (absent = expanded). */
|
|
49
|
+
export declare function readFootCardCollapsed(): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Render the foot card content for the current store snapshot.
|
|
52
|
+
* @param props - store, poller, settings form and open callback from the mount.
|
|
53
|
+
* @returns the card, or null while disabled / host-off.
|
|
54
|
+
*/
|
|
55
|
+
export declare function UsageFootCard(props: UsageFootCardProps): ReactNode;
|
|
@@ -8,13 +8,12 @@
|
|
|
8
8
|
* @module @linxin666/dsh-usage/client/UsageSectionCard
|
|
9
9
|
*/
|
|
10
10
|
import { type ReactNode } from 'react';
|
|
11
|
-
import type {
|
|
11
|
+
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
12
12
|
import type { UsageStoreInstance } from './usage-store.ts';
|
|
13
13
|
/** The settings fields this section edits (immediate-apply semantics). */
|
|
14
14
|
export interface UsageSettings {
|
|
15
15
|
enabled?: boolean;
|
|
16
16
|
pollIntervalSec?: number;
|
|
17
|
-
bubbleMode?: string;
|
|
18
17
|
}
|
|
19
18
|
/** The registration-side face the section's slot entry injects. */
|
|
20
19
|
export interface UsageSectionFace {
|
|
@@ -24,8 +23,8 @@ export interface UsageSectionFace {
|
|
|
24
23
|
poll: () => void;
|
|
25
24
|
/** Force a host probe cycle now (resolves with the fresh overview). */
|
|
26
25
|
refresh: () => void;
|
|
27
|
-
/**
|
|
28
|
-
settings:
|
|
26
|
+
/** The shared configuration form this section's settings row reads and writes. */
|
|
27
|
+
settings: ConfigForm<UsageSettings>;
|
|
29
28
|
}
|
|
30
29
|
export interface UsageSectionProps extends UsageSectionFace {
|
|
31
30
|
/** Close the settings panel (the shell owns the open state). */
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One `document.body` MutationObserver per page, shared by every family
|
|
3
|
+
* plugin bundle.
|
|
4
|
+
*
|
|
5
|
+
* Family plugins that inject at the DOM level must notice when the shell
|
|
6
|
+
* re-renders around their injected node: the sidebar entry rows
|
|
7
|
+
* (sidebar-entry-core), the center-column panel containers
|
|
8
|
+
* (panel-mount-core), and the aggregate shell's column shims. Each consumer
|
|
9
|
+
* used to install its OWN `MutationObserver` on `document.body` with
|
|
10
|
+
* `{ childList: true, subtree: true }`, so a page carrying N family plugins
|
|
11
|
+
* paid N native observers and N callback invocations for EVERY mutation batch
|
|
12
|
+
* the app produced — chat token streaming alone produces many per second, and
|
|
13
|
+
* the count grows with the number of installed plugins.
|
|
14
|
+
*
|
|
15
|
+
* This module keeps exactly ONE body observer per page. The registry lives on
|
|
16
|
+
* `globalThis` under a `Symbol.for` key, so the per-package generated copies
|
|
17
|
+
* of this file (scripts/sync-shared.mjs) and separately bundled plugins all
|
|
18
|
+
* reach the same hub at runtime instead of one hub per module instance.
|
|
19
|
+
* Record subscribers receive the mutations accumulated since the last flush.
|
|
20
|
+
* Consumers that only re-check their DOM use subscribeBodyInvalidations:
|
|
21
|
+
* when all subscribers use that path, the hub retains no mutation records
|
|
22
|
+
* (or detached subtrees) while a background page's animation frames pause.
|
|
23
|
+
* Both paths run at most once per frame. The last subscriber disconnects the
|
|
24
|
+
* observer, cancels the pending frame and releases its records.
|
|
25
|
+
*
|
|
26
|
+
* Failure policy: without a DOM or `MutationObserver` the subscription is a
|
|
27
|
+
* no-op disposer (the same silence the per-consumer observers had), and when
|
|
28
|
+
* `requestAnimationFrame` is unavailable the flush runs synchronously so a
|
|
29
|
+
* subscriber is never silently dropped. A throwing subscriber cannot stop the
|
|
30
|
+
* others.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Subscribe to a coalesced DOM re-check without retaining mutation records.
|
|
34
|
+
* The marked wrapper also works with an older hub, which delivers records
|
|
35
|
+
* that it simply ignores until a page reload picks up the updated hub.
|
|
36
|
+
*/
|
|
37
|
+
export declare function subscribeBodyInvalidations(subscriber: () => void): () => void;
|
|
38
|
+
/**
|
|
39
|
+
* Subscribe to body-level childList mutations.
|
|
40
|
+
* @param subscriber - called at most once per animation frame with the records
|
|
41
|
+
* collected since the previous flush; must be safe to run repeatedly.
|
|
42
|
+
* @returns the disposer removing this subscriber (and the observer when it was
|
|
43
|
+
* the last one).
|
|
44
|
+
*/
|
|
45
|
+
export declare function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar foot card mounting.
|
|
3
|
+
*
|
|
4
|
+
* The foot's only slot (sidebar.footer.action) stacks ABOVE the Settings row
|
|
5
|
+
* and cannot host a block, so the card container is inserted into the shell's
|
|
6
|
+
* foot area directly above the Settings seat — keeping Settings the foot's
|
|
7
|
+
* terminal row instead of hanging a card off the sidebar floor. It self-heals
|
|
8
|
+
* with the page-wide body-mutation hub: a React re-render that displaces the
|
|
9
|
+
* container re-seats it on the next frame, and a whole-pane rebuild is noticed
|
|
10
|
+
* by the same body-level watcher the family entry rows use.
|
|
11
|
+
*
|
|
12
|
+
* The container is a plain div carrying its own React root, so it can never
|
|
13
|
+
* disturb the shell's reconciliation.
|
|
14
|
+
* @module @linxin666/dsh-usage/client/foot-card-mount
|
|
15
|
+
*/
|
|
16
|
+
import { type UsageFootCardProps } from './UsageFootCard.tsx';
|
|
17
|
+
/** Stable data attribute identifying the injected card container. */
|
|
18
|
+
export declare const FOOT_CARD_SELECTOR = "[data-dsh-usage-foot-card]";
|
|
19
|
+
/**
|
|
20
|
+
* Open the settings panel on the usage section. The shell owns the panel's
|
|
21
|
+
* open state and exposes no service for it, so the card replays the user's
|
|
22
|
+
* own path: activate the sidebar Settings trigger, then pick the nav row
|
|
23
|
+
* carrying the section's (localized) label. Every step degrades silently —
|
|
24
|
+
* with the panel already open only the nav pick runs, and a missing row
|
|
25
|
+
* leaves the panel on its default section.
|
|
26
|
+
* @param label - the usage section's nav label in the active locale.
|
|
27
|
+
*/
|
|
28
|
+
export declare function openUsageSettings(label: () => string): void;
|
|
29
|
+
/**
|
|
30
|
+
* Mount the sidebar foot card directly above the Settings row.
|
|
31
|
+
* @param props - the store/poll/settings/open inputs of the apply body.
|
|
32
|
+
* @returns disposer removing the container and its observers.
|
|
33
|
+
*/
|
|
34
|
+
export declare function mountUsageFootCard(props: UsageFootCardProps): () => void;
|
|
@@ -1,27 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-usage browser half — seats the first-level 使用统计 settings section
|
|
3
|
-
* (below the Workshop entry)
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
3
|
+
* (below the Workshop entry) plus the compact usage glance card below the
|
|
4
|
+
* sidebar's Settings row. The section polls the host overview only while it
|
|
5
|
+
* is open; the foot card runs its own relaxed loop because the sidebar foot
|
|
6
|
+
* is permanently mounted. All provider probing and credential handling
|
|
7
|
+
* happens in the host half; this bundle only renders the overview document.
|
|
6
8
|
* @module @linxin666/dsh-usage/client
|
|
7
9
|
*/
|
|
8
10
|
import type { Context as ClientContext } from '@deepseek-ai/cordis';
|
|
9
|
-
import type {
|
|
11
|
+
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
10
12
|
import { type UsageSettings } from './UsageSectionCard.tsx';
|
|
11
13
|
/** Required services. */
|
|
12
14
|
export declare const inject: string[];
|
|
13
15
|
export type { UsageSectionProps, UsageSectionFace } from './UsageSectionCard.tsx';
|
|
14
16
|
export type { UsageUiState } from './usage-store.ts';
|
|
15
17
|
export type { UsageSettings };
|
|
18
|
+
/**
|
|
19
|
+
* One settings namespace a family card binds. The 0.1.7 client exports no spec
|
|
20
|
+
* type (the form controller takes it privately), so the binder's input shape is
|
|
21
|
+
* restated here.
|
|
22
|
+
*/
|
|
23
|
+
export interface UsageSettingsBindSpec<T> {
|
|
24
|
+
/** Settings namespace registered by the owning host plugin. */
|
|
25
|
+
namespace: string;
|
|
26
|
+
/** Narrow one wire section; undefined keeps the last accepted value. */
|
|
27
|
+
decode?: (section: unknown) => T | undefined;
|
|
28
|
+
}
|
|
16
29
|
declare module '@deepseek-ai/cordis' {
|
|
17
30
|
interface Context {
|
|
18
31
|
/**
|
|
19
|
-
* Optional
|
|
20
|
-
*
|
|
21
|
-
*
|
|
32
|
+
* Optional family settings binder provided by dsh-web-settings; absent when
|
|
33
|
+
* that group plugin is not installed, so callers fall back to the native
|
|
34
|
+
* per-entry forms (`ctx.configForms`).
|
|
22
35
|
*/
|
|
23
36
|
webUiSettings?: {
|
|
24
|
-
bind<S>(spec:
|
|
37
|
+
bind<S>(spec: UsageSettingsBindSpec<S>): ConfigForm<S>;
|
|
25
38
|
};
|
|
26
39
|
}
|
|
27
40
|
}
|
|
@@ -46,10 +46,7 @@ export declare const zh: {
|
|
|
46
46
|
'usage.config.title': string;
|
|
47
47
|
'usage.config.enabled': string;
|
|
48
48
|
'usage.config.pollIntervalSec': string;
|
|
49
|
-
'usage.config.
|
|
50
|
-
'usage.config.bubbleMode.always': string;
|
|
51
|
-
'usage.config.bubbleMode.change': string;
|
|
52
|
-
'usage.config.bubbleMode.off': string;
|
|
49
|
+
'usage.config.saveFailed': string;
|
|
53
50
|
'usage.bank.title': string;
|
|
54
51
|
'usage.bank.hint': string;
|
|
55
52
|
'usage.bank.noUsage': string;
|
|
@@ -60,6 +57,11 @@ export declare const zh: {
|
|
|
60
57
|
'usage.bank.save': string;
|
|
61
58
|
'usage.bank.share': string;
|
|
62
59
|
'usage.bank.drawError': string;
|
|
60
|
+
'usage.foot.cost': string;
|
|
61
|
+
'usage.foot.noData': string;
|
|
62
|
+
'usage.foot.open': string;
|
|
63
|
+
'usage.foot.collapse': string;
|
|
64
|
+
'usage.foot.expand': string;
|
|
63
65
|
};
|
|
64
66
|
/** English mirror; every zh key present. */
|
|
65
67
|
export declare const en: Record<UsageKey, string>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alias folding for the enumerated provider routes. Kept in core because the
|
|
3
|
+
* rule is pure — the host enumerates routes and this decides which of two
|
|
4
|
+
* routes serving one adapter family survives.
|
|
5
|
+
* @module @linxin666/dsh-usage/core/provider-routes
|
|
6
|
+
*/
|
|
7
|
+
/** The facts alias folding needs about one enumerated provider route. */
|
|
8
|
+
export interface FoldableRoute {
|
|
9
|
+
/** Provider route key (`deepseek`, `deepseek-official`, custom routes, ...). */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Whether the LLM runtime serves requests on this route; a catalog-only entry is dormant. */
|
|
12
|
+
live: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Drop dormant catalog aliases that shadow a live route of the same adapter
|
|
16
|
+
* family. The pi-ai catalog ships route keys the runtime serves nothing on
|
|
17
|
+
* until a profile declares them: for DeepSeek the catalog entry `deepseek`
|
|
18
|
+
* sits beside the llm-deepseek runtime route `deepseek-official`, and both
|
|
19
|
+
* resolve the same credential (the family's `apiKeyEnv` fallback), so probing
|
|
20
|
+
* and rendering both turns one account into two identical balance rows.
|
|
21
|
+
*
|
|
22
|
+
* A family keeps every live route it has — two configured profiles are two
|
|
23
|
+
* real accounts, not aliases. A route with no adapter, or one whose family
|
|
24
|
+
* has no live route at all, is returned untouched.
|
|
25
|
+
*/
|
|
26
|
+
export declare function foldAliasRoutes<T extends FoldableRoute>(routes: readonly T[]): T[];
|
|
@@ -118,12 +118,25 @@ export interface ProviderSnapshotView {
|
|
|
118
118
|
export interface UsageOverviewView {
|
|
119
119
|
updatedAt: number;
|
|
120
120
|
providers: ProviderSnapshotView[];
|
|
121
|
-
/** The provider the
|
|
121
|
+
/** The provider the collapsed sidebar strip and the header highlight; `source` says how it was picked. */
|
|
122
122
|
current: {
|
|
123
123
|
provider?: string;
|
|
124
124
|
model?: string;
|
|
125
125
|
/** `live` — last request seen this boot; `default` — the agent default model. */
|
|
126
126
|
source: 'live' | 'default';
|
|
127
|
+
/**
|
|
128
|
+
* Resolved display name (snapshot first, route-derived otherwise), so the
|
|
129
|
+
* strip needs no name-resolution of its own. Optional so an older host
|
|
130
|
+
* document still renders.
|
|
131
|
+
*/
|
|
132
|
+
displayName?: string;
|
|
133
|
+
/**
|
|
134
|
+
* Today's ledger totals for the provider's adapter family (the route
|
|
135
|
+
* itself when adapter-less), carrying tokens, calls, and the priced cost.
|
|
136
|
+
* Absent on a day without usage; optional for the same older-host
|
|
137
|
+
* tolerance as displayName.
|
|
138
|
+
*/
|
|
139
|
+
today?: UsageTokenTotals;
|
|
127
140
|
};
|
|
128
141
|
usage: {
|
|
129
142
|
today: {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage trust fence (issue #1592): loopback (the desktop) always passes, and a
|
|
3
|
+
* live paired-device cookie is an additional allow path when remote-web-ui is
|
|
4
|
+
* loaded — the same family fence the skill center uses. Without that service
|
|
5
|
+
* the fence stays loopback-only, so an unpaired LAN browser keeps its 403.
|
|
6
|
+
* The decision logic lives in the generated pair-access.ts copy (shared by
|
|
7
|
+
* git-graph / pet / skill-explorer).
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage } from 'node:http';
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
11
|
+
/**
|
|
12
|
+
* Whether this request may read the usage overview or trigger a refresh.
|
|
13
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
14
|
+
* @param request - the incoming HTTP request.
|
|
15
|
+
* @returns true for loopback, or a live paired-device cookie.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isUsageAllowed(ctx: Context, request: IncomingMessage): boolean;
|
package/lib/types/host/http.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared JSON body/response helpers for the host route families: one strict
|
|
3
3
|
* bounded body reader, one lenient bounded body reader, one JSON object
|
|
4
|
-
* narrow,
|
|
4
|
+
* narrow, one JSON writer, and the outbound request-init helper that keeps a
|
|
5
|
+
* host fetch body decodable (withIdentityEncoding). Previously the readers
|
|
6
|
+
* were copy-pasted across the
|
|
5
7
|
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
|
|
6
8
|
* family's route module) with drifting contracts: body caps ranging 4 KiB to
|
|
7
9
|
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
|
|
@@ -34,6 +36,21 @@ export declare function readJsonBody(req: IncomingMessage, opts?: {
|
|
|
34
36
|
}): Promise<unknown | null>;
|
|
35
37
|
/** Narrow a value to a JSON object, or undefined when it is not one. */
|
|
36
38
|
export declare function asJsonObject(value: unknown): Record<string, unknown> | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Request init that asks a remote origin for an uncompressed body.
|
|
41
|
+
*
|
|
42
|
+
* The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
|
|
43
|
+
* the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
|
|
44
|
+
* Node's built-in `fetch()` reads. That cross-major wrapper drops the
|
|
45
|
+
* `content-encoding` header and automatic decompression, so a plain fetch
|
|
46
|
+
* resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
|
|
47
|
+
* consumer can read. Every host fetch that parses a remote body must request
|
|
48
|
+
* identity encoding. Caller headers survive; `accept-encoding` is forced to
|
|
49
|
+
* `identity` because a caller value has no decoder behind it in this host.
|
|
50
|
+
* @param init - the caller's request init (signal, headers, method, ...).
|
|
51
|
+
* @returns a copy with `accept-encoding: identity` merged in.
|
|
52
|
+
*/
|
|
53
|
+
export declare function withIdentityEncoding(init?: RequestInit): RequestInit;
|
|
37
54
|
/**
|
|
38
55
|
* Write one JSON response. Default headers are the family defaults
|
|
39
56
|
* (content-type and referrer-policy); caller headers are appended or
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing trust fence shared by plugins that expose host routes: loopback
|
|
3
|
+
* (the desktop) always passes; a live paired-device cookie is an additional
|
|
4
|
+
* allow path when remote-web-ui is loaded. The consuming plugin never
|
|
5
|
+
* depends on that plugin — without the service the fence stays
|
|
6
|
+
* loopback-only.
|
|
7
|
+
*
|
|
8
|
+
* Per-package wrappers (access.ts) call this with their own name so each
|
|
9
|
+
* plugin keeps a self-describing export; the security decision lives only
|
|
10
|
+
* here.
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage } from 'node:http';
|
|
13
|
+
/** Structural pairing lookup (no package dependency on remote-web-ui). */
|
|
14
|
+
interface PairingAccess {
|
|
15
|
+
isPairedDevice(request: IncomingMessage): boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Structural host-context shape: shared sources carry no @deepseek-ai
|
|
19
|
+
* dependency (the shared package must typecheck standalone), so the fence
|
|
20
|
+
* reads only the two members it needs; cordis Context satisfies this.
|
|
21
|
+
* ctx.get is optional on the test harness; production Context always has it.
|
|
22
|
+
*/
|
|
23
|
+
interface LookupCtx {
|
|
24
|
+
get?(name: string, strict?: boolean): unknown;
|
|
25
|
+
remoteWebUiPairing?: PairingAccess;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether this request may enter the plugin's host routes.
|
|
29
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
30
|
+
* @param request - the incoming HTTP request.
|
|
31
|
+
* @returns true for loopback, or a live paired-device cookie.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isPairedOrLoopbackAllowed(ctx: LookupCtx, request: IncomingMessage): boolean;
|
|
34
|
+
export {};
|
|
@@ -1,14 +1,23 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
1
2
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
2
3
|
import type { UsageService } from './usage-service.ts';
|
|
3
4
|
export declare const USAGE_API_PREFIX = "/api/dsh-usage";
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Overview route: provider balances, plan quotas, and token usage totals.
|
|
7
|
+
* Personal account data behind the family trust fence — loopback always
|
|
8
|
+
* passes, and a live paired-device cookie passes too when remote-web-ui is
|
|
9
|
+
* loaded (issue #1592: the sidebar usage panel reads this from a paired LAN
|
|
10
|
+
* browser); every unpaired LAN request keeps its 403.
|
|
11
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
12
|
+
* @param service - the usage service.
|
|
13
|
+
* @returns the route.
|
|
8
14
|
*/
|
|
9
|
-
export declare function makeUsageOverviewRoute(service: UsageService): WebRoute;
|
|
15
|
+
export declare function makeUsageOverviewRoute(ctx: Context, service: UsageService): WebRoute;
|
|
10
16
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
17
|
+
* Manual refresh: forces one probe cycle now and answers with the fresh
|
|
18
|
+
* overview. Same trust fence as the overview route.
|
|
19
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
20
|
+
* @param service - the usage service.
|
|
21
|
+
* @returns the route.
|
|
13
22
|
*/
|
|
14
|
-
export declare function makeUsageRefreshRoute(service: UsageService): WebRoute;
|
|
23
|
+
export declare function makeUsageRefreshRoute(ctx: Context, service: UsageService): WebRoute;
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The dsh-usage host service: folds live session usage into the persistent
|
|
3
3
|
* ledger, probes each configured provider's balance/coding-plan endpoint on
|
|
4
|
-
* a poll cycle, and
|
|
5
|
-
*
|
|
6
|
-
* overview document.
|
|
4
|
+
* a poll cycle, and serves the current provider's strip-ready view inside
|
|
5
|
+
* the overview document. Secrets stay in the host process; the browser only
|
|
6
|
+
* ever sees the overview document.
|
|
7
7
|
* @module @linxin666/dsh-usage/host/usage-service
|
|
8
8
|
*/
|
|
9
9
|
import type { Context } from '@deepseek-ai/cordis';
|
|
10
|
-
import type {
|
|
11
|
-
/** Source tag the plugin stamps onto pet announcements. */
|
|
12
|
-
export declare const USAGE_ANNOUNCE_SOURCE = "dsh-usage";
|
|
10
|
+
import type { UsageOverviewView } from '../core/types.ts';
|
|
13
11
|
/**
|
|
14
12
|
* Persisted accrual state for the official DeepSeek family's real spend:
|
|
15
13
|
* consecutive observations of the official CNY balance, decreases counted
|
|
@@ -25,54 +23,11 @@ export interface SpendWatch {
|
|
|
25
23
|
/** The balance the previous observation ended on, when one exists. */
|
|
26
24
|
lastBalanceCny?: number;
|
|
27
25
|
}
|
|
28
|
-
/** Poll-loop
|
|
26
|
+
/** Poll-loop options; re-applied live on settings change. */
|
|
29
27
|
export interface UsageServiceOptions {
|
|
30
28
|
pollIntervalSec: number;
|
|
31
|
-
bubbleMode: 'always' | 'change' | 'off';
|
|
32
29
|
retainDays: number;
|
|
33
30
|
}
|
|
34
|
-
/** Format a balance for display: symbol prefix when known, code suffix otherwise. */
|
|
35
|
-
export declare function formatMoney(currency: string, totalBalance: string): string;
|
|
36
|
-
/** Map a used percent to the announcement tone. */
|
|
37
|
-
export declare function planTone(percent: number): 'ok' | 'warn' | 'low';
|
|
38
|
-
/** Announcement context the service computes per poll: today's family spend and the DeepSeek period. */
|
|
39
|
-
export interface AnnounceContext {
|
|
40
|
-
/** Today's ledger spend for the announced provider's family (CNY; 0 = unpriced). */
|
|
41
|
-
todayCost?: number;
|
|
42
|
-
/** Whether DeepSeek peak pricing is in effect right now. */
|
|
43
|
-
peak?: boolean;
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Build the raw pet announce payload for one provider snapshot, or
|
|
47
|
-
* undefined when nothing worth announcing exists. Pure: every payload this
|
|
48
|
-
* returns satisfies the pet's `parseAnnouncement` contract — plan
|
|
49
|
-
* announcements require a numeric percent, so percent-less windows never
|
|
50
|
-
* announce (the pet validator would silently drop them). A priced family
|
|
51
|
-
* (DeepSeek) with spend today announces a cost bubble first; balance-only
|
|
52
|
-
* families announce the balance; plan families announce their tightest
|
|
53
|
-
* percent window.
|
|
54
|
-
*/
|
|
55
|
-
export declare function buildAnnouncement(snapshot: Pick<ProviderSnapshotView, 'displayName' | 'balance' | 'plan'>, context?: AnnounceContext): Record<string, unknown> | undefined;
|
|
56
|
-
/**
|
|
57
|
-
* Compact token-count display for the usage fallback bubble: `9805`,
|
|
58
|
-
* `8.7万`, `1.2亿`. The host-authored bubble copy is zh (like every other
|
|
59
|
-
* line this service speaks), so the magnitudes follow the zh convention.
|
|
60
|
-
*/
|
|
61
|
-
export declare function formatTokens(count: number): string;
|
|
62
|
-
/**
|
|
63
|
-
* The usage fallback for the session's provider: today's token consumption
|
|
64
|
-
* and call count, the one fact the ledger owns for every provider. Providers
|
|
65
|
-
* without a readable balance/plan endpoint (relay stations, local runtimes,
|
|
66
|
-
* token-plan vendors — and probeable providers whose probes are failing)
|
|
67
|
-
* would otherwise leave the pet bubble permanently silent even while their
|
|
68
|
-
* sessions run. Undefined when the provider has no usage today: a bubble
|
|
69
|
-
* about nothing is noise, not information. Pure; the payload satisfies the
|
|
70
|
-
* pet's `parseAnnouncement` contract.
|
|
71
|
-
*/
|
|
72
|
-
export declare function buildLedgerAnnouncement(input: {
|
|
73
|
-
displayName: string;
|
|
74
|
-
totals: UsageTokenTotals;
|
|
75
|
-
}): Record<string, unknown> | undefined;
|
|
76
31
|
export declare class UsageService {
|
|
77
32
|
private readonly ctx;
|
|
78
33
|
private options;
|
|
@@ -85,7 +40,7 @@ export declare class UsageService {
|
|
|
85
40
|
private spendWatch;
|
|
86
41
|
/** Per-live-session route attribution (WeakMap: disposed sessions age out). */
|
|
87
42
|
private readonly sessionRoutes;
|
|
88
|
-
/** The most recent route seen this boot; the
|
|
43
|
+
/** The most recent route seen this boot; the sidebar strip and the header highlight follow it. */
|
|
89
44
|
private current;
|
|
90
45
|
private sessionListenerDisposer;
|
|
91
46
|
private pollTimer;
|
|
@@ -98,7 +53,6 @@ export declare class UsageService {
|
|
|
98
53
|
/** True once loadPersisted finished; nothing may overwrite the files before that. */
|
|
99
54
|
private loaded;
|
|
100
55
|
private disposed;
|
|
101
|
-
private lastSignature;
|
|
102
56
|
/** Last prune guard: once per local day, and whenever retention shrinks. */
|
|
103
57
|
private lastPrune;
|
|
104
58
|
constructor(ctx: Context, options: UsageServiceOptions);
|
|
@@ -106,22 +60,33 @@ export declare class UsageService {
|
|
|
106
60
|
start(): void;
|
|
107
61
|
/**
|
|
108
62
|
* Stop timers and flush pending ledger writes. The returned promise
|
|
109
|
-
* resolves after the final flush lands, so a successor instance (
|
|
110
|
-
*
|
|
63
|
+
* resolves after the final flush lands, so a successor instance (the Host
|
|
64
|
+
* reloads the profile row on a config change) can serialize its first load
|
|
65
|
+
* behind it.
|
|
111
66
|
*/
|
|
112
67
|
stop(): Promise<void>;
|
|
113
|
-
/** Re-apply options
|
|
68
|
+
/** Re-apply options to this running instance; retention shrink prunes now. */
|
|
114
69
|
applyOptions(options: UsageServiceOptions): void;
|
|
115
70
|
/** Force one poll now (manual refresh route); joins an in-flight cycle. */
|
|
116
71
|
refresh(): Promise<void>;
|
|
117
72
|
/** Assemble the overview document the browser section renders. */
|
|
118
73
|
overview(): UsageOverviewView;
|
|
74
|
+
/**
|
|
75
|
+
* The strip-ready current-provider view: the resolved display name plus
|
|
76
|
+
* today's ledger totals for the provider's adapter family (the route
|
|
77
|
+
* itself when adapter-less). `today` is absent on a day without usage —
|
|
78
|
+
* a strip about nothing is noise, not information.
|
|
79
|
+
*/
|
|
80
|
+
private currentView;
|
|
119
81
|
/** One local day aggregated per provider. */
|
|
120
82
|
private daySummary;
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Today's ledger totals for one provider's adapter family — the exact
|
|
85
|
+
* route when adapter-less, the merged family otherwise, so aliased routes
|
|
86
|
+
* of one account (the deepseek catalog id and its runtime alias) fold
|
|
87
|
+
* together.
|
|
88
|
+
*/
|
|
89
|
+
private familyUsageToday;
|
|
125
90
|
/**
|
|
126
91
|
* Accrue the official DeepSeek family's real spend from observed CNY
|
|
127
92
|
* balance decreases; a balance rise is a top-up and never counts. The
|
|
@@ -134,7 +99,7 @@ export declare class UsageService {
|
|
|
134
99
|
/**
|
|
135
100
|
* The display name for a route key: the LLM runtime's first, then the
|
|
136
101
|
* adapter's, then the id itself — the snapshot may not exist for
|
|
137
|
-
* adapter-less routes, yet the
|
|
102
|
+
* adapter-less routes, yet the sidebar strip still needs a legible title.
|
|
138
103
|
*/
|
|
139
104
|
private routeDisplayName;
|
|
140
105
|
private onSessionEvent;
|
|
@@ -162,11 +127,17 @@ export declare class UsageService {
|
|
|
162
127
|
private persistSnapshots;
|
|
163
128
|
private rearmPoll;
|
|
164
129
|
/**
|
|
165
|
-
* One poll cycle: enumerate routes, resolve credentials, probe
|
|
130
|
+
* One poll cycle: enumerate routes, resolve credentials, probe.
|
|
166
131
|
* A call while a cycle is already running joins that cycle instead of
|
|
167
132
|
* returning immediately, so a manual refresh always waits for real probes.
|
|
168
133
|
*/
|
|
169
134
|
pollNow(): Promise<void>;
|
|
135
|
+
/**
|
|
136
|
+
* Every route the service probes and renders: the runtime's live providers
|
|
137
|
+
* plus the configurable directory's catalog entries, then alias-folded so a
|
|
138
|
+
* dormant catalog entry cannot shadow the live route of the same adapter
|
|
139
|
+
* family (see foldAliasRoutes).
|
|
140
|
+
*/
|
|
170
141
|
private listProviderRoutes;
|
|
171
142
|
private probeRoute;
|
|
172
143
|
/**
|
|
@@ -182,19 +153,4 @@ export declare class UsageService {
|
|
|
182
153
|
private piAiProfile;
|
|
183
154
|
/** The DeepSeek official adapter's credential reference name. */
|
|
184
155
|
private deepseekApiKeyEnv;
|
|
185
|
-
/**
|
|
186
|
-
* Announce the current provider's spend, balance, or plan usage to the pet.
|
|
187
|
-
* In `change` mode only meaningful value changes re-announce; `off` skips.
|
|
188
|
-
* The TTL rides the poll interval (bubble_mode `always` re-announces every
|
|
189
|
-
* cycle, so a TTL of two cycles + margin keeps the bubble continuous
|
|
190
|
-
* across polls; the pet contract caps the ceiling). A route id the
|
|
191
|
-
* catalogs spell differently than the snapshot keys falls back to its
|
|
192
|
-
* adapter family's snapshot. When the provider has no announceable probe
|
|
193
|
-
* fact (no adapter, failing probes, percent-less windows) the bubble falls
|
|
194
|
-
* back to the provider's today ledger usage, and with neither fact nor
|
|
195
|
-
* usage it stays silent. Fully guarded: a malformed snapshot or a failing
|
|
196
|
-
* pet service must never break the poll loop, and a disposed service never
|
|
197
|
-
* announces.
|
|
198
|
-
*/
|
|
199
|
-
private announceCurrent;
|
|
200
156
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -4,13 +4,17 @@ import z from 'schemastery';
|
|
|
4
4
|
import { type UsageServiceOptions } from './host/usage-service.ts';
|
|
5
5
|
export declare const name = "dsh-usage";
|
|
6
6
|
export declare const inject: string[];
|
|
7
|
+
/**
|
|
8
|
+
* Family settings namespace this row serves. Since 0.1.7 the settings surface
|
|
9
|
+
* addresses one form per profile entry id and the Host generates this row's
|
|
10
|
+
* page from the Config schema below, so the namespace is only the alias the
|
|
11
|
+
* family settings bridge (dsh-web-settings) resolves onto this row's entry id.
|
|
12
|
+
*/
|
|
7
13
|
export declare const USAGE_SETTINGS_NAMESPACE: SettingsNamespace;
|
|
8
14
|
export interface Config {
|
|
9
15
|
enabled?: boolean;
|
|
10
16
|
/** Provider probe cycle in seconds; 30-3600. */
|
|
11
17
|
pollIntervalSec?: number;
|
|
12
|
-
/** Pet bubble mode: always (refreshes each poll), change (only on value change), off. */
|
|
13
|
-
bubbleMode?: string;
|
|
14
18
|
/** Ledger retention in local days. */
|
|
15
19
|
retainDays?: number;
|
|
16
20
|
}
|
|
@@ -19,4 +23,12 @@ export interface ResolvedConfig extends UsageServiceOptions {
|
|
|
19
23
|
enabled: boolean;
|
|
20
24
|
}
|
|
21
25
|
export declare function resolveConfig(config?: Config): ResolvedConfig;
|
|
26
|
+
/**
|
|
27
|
+
* Host body. The plugin's own Config schema above is the settings document the
|
|
28
|
+
* 0.1.7 Host serves for this row; the effective config arrives here as the
|
|
29
|
+
* `config` argument the Loader resolved and the Host reloads the row when the
|
|
30
|
+
* user saves a change, so nothing in this body observes or writes settings.
|
|
31
|
+
* @param ctx - host root context.
|
|
32
|
+
* @param config - the row's resolved configuration.
|
|
33
|
+
*/
|
|
22
34
|
export declare const apply: (ctx: Context, config?: Config) => void;
|