@linxin666/dsh-usage 0.4.1 → 0.4.3
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 +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +112 -11
- package/lib/client.js.map +1 -1
- package/lib/index.js +222 -45
- package/lib/types/client/body-mutations.d.ts +4 -4
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/adapters.d.ts +45 -0
- package/lib/types/index.d.ts +61 -10
- package/lib/types/mount-once.d.ts +38 -7
- package/package.json +15 -15
- package/src/client/UsageSectionCard.tsx +8 -1
- package/src/client/body-mutations.ts +4 -4
- package/src/client/index.ts +22 -9
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/adapters.ts +56 -0
- package/src/host/usage-service.ts +19 -2
- package/src/index.ts +138 -43
- package/src/mount-once.ts +107 -13
|
@@ -4,19 +4,50 @@
|
|
|
4
4
|
* the loader accepts a standalone install of the same package side by side;
|
|
5
5
|
* without this guard the second instance would still re-register the same
|
|
6
6
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
7
|
-
* and fail the boot. mountOnce makes
|
|
8
|
-
*
|
|
9
|
-
* package name in the client module host).
|
|
7
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
8
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
9
|
+
* deduped by package name in the client module host).
|
|
10
|
+
*
|
|
11
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
12
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
13
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
14
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
15
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
16
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
17
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
18
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
19
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
20
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
21
|
+
* restart recovered it).
|
|
22
|
+
*
|
|
23
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
24
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
25
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
26
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
27
|
+
* dedupes against it.
|
|
10
28
|
*
|
|
11
29
|
* The registry rides a global symbol so two module instances of the same
|
|
12
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
13
|
-
*
|
|
30
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
31
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
32
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
33
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
34
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
35
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
36
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
37
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
38
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
39
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
40
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
41
|
+
* mounted after it failed with "claims.get is not a function".
|
|
42
|
+
*
|
|
43
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
14
44
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
15
45
|
*/
|
|
16
46
|
/**
|
|
17
47
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
18
|
-
* The first mount registers normally and
|
|
19
|
-
*
|
|
48
|
+
* The first mount registers normally and releases the name when its fiber
|
|
49
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
50
|
+
* then runs, unless its own fiber disposes first.
|
|
20
51
|
* @param packageName - npm package identity shared by every install source.
|
|
21
52
|
* @param fn - the original plugin apply.
|
|
22
53
|
* @returns an apply of the same shape.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-usage",
|
|
3
3
|
"description": "Usage statistics plugin for the dsh web GUI: per-provider balance and coding-plan quota detection plus a live token usage ledger, with a compact price glance card above the sidebar's Settings row",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.3",
|
|
5
5
|
"icon": "icon.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"license": "Apache-2.0",
|
|
34
34
|
"dsh": {
|
|
35
35
|
"engines": {
|
|
36
|
-
"dsh": ">=0.1.7-rc.
|
|
36
|
+
"dsh": ">=0.1.7-rc.2"
|
|
37
37
|
},
|
|
38
38
|
"bundle": {
|
|
39
39
|
"patch": "./cordis.patch.yml"
|
|
@@ -50,25 +50,25 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"schemastery": "^3.18.
|
|
53
|
+
"@deepseek-ai/schemastery": "^3.18.4"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@deepseek-ai/dsh": ">=0.1.7-rc.
|
|
56
|
+
"@deepseek-ai/dsh": ">=0.1.7-rc.2",
|
|
57
57
|
"react": "^18.2.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
61
|
-
"@deepseek-ai/dsh-credentials": "^0.1.7-rc.
|
|
62
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.7-rc.
|
|
63
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.7-rc.
|
|
64
|
-
"@deepseek-ai/dsh-client-store": "^0.1.7-rc.
|
|
65
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.
|
|
66
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.
|
|
67
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.
|
|
68
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.
|
|
69
|
-
"@deepseek-ai/dsh-llm": "^0.1.7-rc.
|
|
70
|
-
"@deepseek-ai/dsh-session": "^0.1.7-rc.
|
|
71
|
-
"@deepseek-ai/dsh-settings": "^0.1.7-rc.
|
|
61
|
+
"@deepseek-ai/dsh-credentials": "^0.1.7-rc.2",
|
|
62
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.7-rc.2",
|
|
63
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.7-rc.2",
|
|
64
|
+
"@deepseek-ai/dsh-client-store": "^0.1.7-rc.2",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.2",
|
|
66
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-session": "^0.1.7-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-settings": "^0.1.7-rc.2",
|
|
72
72
|
"@testing-library/dom": "^10.4.1",
|
|
73
73
|
"@testing-library/react": "^16.3.2",
|
|
74
74
|
"@types/node": "^22.20.0",
|
|
@@ -128,6 +128,10 @@ function balanceLine(provider: ProviderSnapshotView): ReactNode {
|
|
|
128
128
|
}
|
|
129
129
|
if (provider.credential === 'oauth') return <span className={styles.muted}>{t('usage.oauth')}</span>
|
|
130
130
|
if (provider.credential === 'none') return <span className={styles.muted}>{t('usage.balance.noCredential')}</span>
|
|
131
|
+
// balanceSupported === false is the origin gate: the adapter has a balance
|
|
132
|
+
// endpoint but it belongs to another provider's account (issue #1688), so
|
|
133
|
+
// the row says so instead of silently vanishing from the card.
|
|
134
|
+
if (provider.balanceSupported === false) return <span className={styles.muted}>{t('usage.balance.unsupported')}</span>
|
|
131
135
|
if (!provider.supported) return <span className={styles.muted}>{t('usage.balance.unsupported')}</span>
|
|
132
136
|
return null
|
|
133
137
|
}
|
|
@@ -298,7 +302,10 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
298
302
|
<span className={styles.cardTitle}>{t('usage.balance')}</span>
|
|
299
303
|
{(() => {
|
|
300
304
|
const configured = snapshot.providers.filter(isConfigured)
|
|
301
|
-
|
|
305
|
+
// Include rows whose balance is deliberately not applicable
|
|
306
|
+
// (another origin's endpoint): the row itself explains why, which
|
|
307
|
+
// a single generic line cannot (#1688).
|
|
308
|
+
const rows = configured.filter((provider) => provider.balanceSupported === true || provider.balanceSupported === false || (provider.balanceSupported === undefined && provider.supported))
|
|
302
309
|
if (rows.length === 0) {
|
|
303
310
|
return <span className={styles.muted}>{configured.length === 0 ? t('usage.balance.noneConfigured') : t('usage.balance.unsupported')}</span>
|
|
304
311
|
}
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* plugin bundle.
|
|
5
5
|
*
|
|
6
6
|
* Family plugins that inject at the DOM level must notice when the shell
|
|
7
|
-
* re-renders around their injected node: the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* re-renders around their injected node: the aggregate shell's column shims
|
|
8
|
+
* and any plugin surface still extending the shell from outside the official
|
|
9
|
+
* slot seats. Each consumer used to install its OWN `MutationObserver` on
|
|
10
|
+
* `document.body` with
|
|
11
11
|
* `{ childList: true, subtree: true }`, so a page carrying N family plugins
|
|
12
12
|
* paid N native observers and N callback invocations for EVERY mutation batch
|
|
13
13
|
* the app produced — chat token streaming alone produces many per second, and
|
package/src/client/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { createUsageStore, type UsageStoreInstance } from './usage-store.ts'
|
|
|
21
21
|
import { UsageSectionCard, type UsageSectionFace, type UsageSettings } from './UsageSectionCard.tsx'
|
|
22
22
|
import { mountUsageFootCard, openUsageSettings } from './foot-card-mount.tsx'
|
|
23
23
|
import { NS, en, zh, t } from './locales.ts'
|
|
24
|
+
import { createServedEntryForm } from './settings-entry-form.ts'
|
|
24
25
|
import type { UsageOverviewView } from '../core/types.ts'
|
|
25
26
|
|
|
26
27
|
/** The host usage API as the browser sees it (same-origin JSON endpoints). */
|
|
@@ -42,13 +43,25 @@ async function usageFetch<T>(path: string, method: 'GET' | 'POST'): Promise<T> {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
const usageApi: UsageHttpApi = {
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
// DOCUMENT-RELATIVE routes (issue #1707): the GUI is served with
|
|
47
|
+
// `<base href="./">`, so a sub-path deployment resolves these against its
|
|
48
|
+
// entry directory instead of escaping to the origin root.
|
|
49
|
+
overview: () => usageFetch('api/dsh-usage/overview', 'GET'),
|
|
50
|
+
refresh: () => usageFetch('api/dsh-usage/refresh', 'POST'),
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Settings namespace the section edits: the family identity of this plugin's
|
|
55
|
+
* own settings form, and the row id a standalone bundle install carries.
|
|
56
|
+
*/
|
|
50
57
|
const USAGE_SETTINGS_NS = 'dsh-usage'
|
|
51
58
|
|
|
59
|
+
/** Profile entry id the family aggregate's generated row carries. */
|
|
60
|
+
const AGGREGATE_ENTRY_ID = 'web-ui-usage'
|
|
61
|
+
|
|
62
|
+
/** Profile entry ids this package's patch rows carry, most likely first. */
|
|
63
|
+
const USAGE_ENTRY_IDS: readonly string[] = [AGGREGATE_ENTRY_ID, 'usage', USAGE_SETTINGS_NS]
|
|
64
|
+
|
|
52
65
|
/** First-level nav position: directly below the Workshop section (order 150). */
|
|
53
66
|
const SECTION_ORDER = 151
|
|
54
67
|
|
|
@@ -98,15 +111,15 @@ export function apply(ctx: ClientContext): void {
|
|
|
98
111
|
}, 'dsh-usage: dictionaries')
|
|
99
112
|
|
|
100
113
|
// The family binder is what maps this namespace onto the row's profile entry
|
|
101
|
-
// id and hands back the native form. Without the group plugin the
|
|
102
|
-
//
|
|
103
|
-
// the
|
|
104
|
-
//
|
|
105
|
-
//
|
|
114
|
+
// id and hands back the native form. Without the group plugin the shared forms
|
|
115
|
+
// service is addressed on the entry id the describe mirror justifies, rebound
|
|
116
|
+
// as soon as the mirror answers: the namespace itself is only an entry id on a
|
|
117
|
+
// standalone install, so binding it on the aggregate left the form unavailable
|
|
118
|
+
// and the row's controls disabled.
|
|
106
119
|
const binder = ctx.get('webUiSettings')
|
|
107
120
|
const settingsForm = binder !== undefined
|
|
108
121
|
? binder.bind<UsageSettings>({ namespace: USAGE_SETTINGS_NS })
|
|
109
|
-
:
|
|
122
|
+
: createServedEntryForm<UsageSettings>({ forms: ctx.configForms, entryIds: USAGE_ENTRY_IDS })
|
|
110
123
|
|
|
111
124
|
// One store instance per apply body; the section mounts and unmounts with
|
|
112
125
|
// the settings page, and the store survives between visits so the last
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
3
|
+
/**
|
|
4
|
+
* Family settings form bound to the profile entry id a Host actually serves.
|
|
5
|
+
*
|
|
6
|
+
* Under the 0.1.7 settings model a form is addressed by PROFILE ENTRY ID, while
|
|
7
|
+
* a family card knows only its own settings namespace. The family binder
|
|
8
|
+
* (`ctx.get('webUiSettings')`, published by dsh-web-settings) resolves one onto
|
|
9
|
+
* the other and is the preferred transport; this module is the fallback for a
|
|
10
|
+
* page that serves no binder — the group plugin is not installed, or its client
|
|
11
|
+
* half has not applied yet (the aggregate mounts its children in order, and a
|
|
12
|
+
* child still waiting on its own injects publishes nothing).
|
|
13
|
+
*
|
|
14
|
+
* The shared describe mirror is the only local evidence of which row id this
|
|
15
|
+
* profile serves, but it answers asynchronously: at plugin activation it usually
|
|
16
|
+
* holds nothing yet. A one-shot guess therefore cannot be right for every
|
|
17
|
+
* deployment, and the form is bound once per session:
|
|
18
|
+
*
|
|
19
|
+
* - binding the bare namespace made every save answer `No configurable plugin
|
|
20
|
+
* entry "<namespace>"` on an aggregate install (the namespace is not an entry
|
|
21
|
+
* id there), which the card reported as "the deployment did not accept these
|
|
22
|
+
* values";
|
|
23
|
+
* - binding the aggregate row id would break a standalone install, whose row id
|
|
24
|
+
* is the namespace or the package's own `ui-*` row.
|
|
25
|
+
*
|
|
26
|
+
* So the fallback starts on the most likely row and REBINDS the moment the
|
|
27
|
+
* mirror names one of this package's rows. Until the mirror answers, the form
|
|
28
|
+
* reports the binding it holds (usually `loading`), which the card renders as an
|
|
29
|
+
* unavailable form rather than a doomed one.
|
|
30
|
+
*/
|
|
31
|
+
import type { ConfigForm, ConfigFormSnapshot, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
32
|
+
|
|
33
|
+
/** Options of {@link createServedEntryForm}. */
|
|
34
|
+
export interface ServedEntryFormOptions<T> {
|
|
35
|
+
/** The shared configuration forms service (`ctx.configForms`). */
|
|
36
|
+
forms: ConfigForms
|
|
37
|
+
/**
|
|
38
|
+
* Profile entry ids this package's rows carry, most likely first: the
|
|
39
|
+
* aggregate's generated row, the package's own standalone row, then the bare
|
|
40
|
+
* namespace. The first is the fallback while the mirror has no answer; the
|
|
41
|
+
* last is the answer when the mirror serves none of them.
|
|
42
|
+
*/
|
|
43
|
+
entryIds: readonly string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The snapshot a form reports before the Host has answered with this entry. */
|
|
47
|
+
function pendingSnapshot<T>(): ConfigFormSnapshot<T> {
|
|
48
|
+
return {
|
|
49
|
+
status: 'loading',
|
|
50
|
+
value: undefined,
|
|
51
|
+
base: undefined,
|
|
52
|
+
user: undefined,
|
|
53
|
+
revision: undefined,
|
|
54
|
+
writable: false,
|
|
55
|
+
mode: 'host',
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a settings form bound to the profile entry id this Host serves, and
|
|
61
|
+
* rebound whenever the shared describe mirror names a different one of this
|
|
62
|
+
* package's rows.
|
|
63
|
+
* @param options - the shared forms service and this package's candidate row ids.
|
|
64
|
+
* @returns a form delegating to the currently resolved entry's own form.
|
|
65
|
+
*/
|
|
66
|
+
export function createServedEntryForm<T>(options: ServedEntryFormOptions<T>): ConfigForm<T> {
|
|
67
|
+
const { forms, entryIds } = options
|
|
68
|
+
const fallbackId = entryIds[0]
|
|
69
|
+
const namespaceId = entryIds[entryIds.length - 1]
|
|
70
|
+
const listeners = new Set<() => void>()
|
|
71
|
+
let boundId: string | undefined
|
|
72
|
+
let bound: ConfigForm<T> | undefined
|
|
73
|
+
let offBound: (() => void) | undefined
|
|
74
|
+
let snapshot: ConfigFormSnapshot<T> = pendingSnapshot<T>()
|
|
75
|
+
|
|
76
|
+
/** Republish: the delegated snapshot when one is bound, the pending one otherwise. */
|
|
77
|
+
const publish = (): void => {
|
|
78
|
+
if (bound !== undefined) snapshot = bound.getSnapshot()
|
|
79
|
+
for (const listener of [...listeners]) listener()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The entry id the mirror currently justifies. An unanswered or empty mirror
|
|
84
|
+
* is not evidence of absence, so it keeps the first candidate; a mirror that
|
|
85
|
+
* answers without any of this package's rows leaves the namespace itself.
|
|
86
|
+
* @returns the entry id to bind.
|
|
87
|
+
*/
|
|
88
|
+
const resolve = (): string => {
|
|
89
|
+
let served: readonly string[] | undefined
|
|
90
|
+
try {
|
|
91
|
+
served = forms.describe().getSnapshot().view?.namespaces.map(row => row.ns)
|
|
92
|
+
} catch {
|
|
93
|
+
// A mirror that refuses the read is unanswered too, not absent.
|
|
94
|
+
served = undefined
|
|
95
|
+
}
|
|
96
|
+
if (served === undefined || served.length === 0) return fallbackId
|
|
97
|
+
return entryIds.find(id => served.includes(id)) ?? namespaceId
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Bind (or rebind) the resolved entry's form; a no-op while it is unchanged. */
|
|
101
|
+
const bind = (): void => {
|
|
102
|
+
const target = resolve()
|
|
103
|
+
if (target === boundId) return
|
|
104
|
+
offBound?.()
|
|
105
|
+
offBound = undefined
|
|
106
|
+
let form: ConfigForm<T>
|
|
107
|
+
try {
|
|
108
|
+
form = forms.get<T>(target)
|
|
109
|
+
} catch {
|
|
110
|
+
// A service that refuses the id stays unbound; the next mirror answer
|
|
111
|
+
// retries rather than pinning a wrong entry for the session.
|
|
112
|
+
boundId = undefined
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
boundId = target
|
|
116
|
+
bound = form
|
|
117
|
+
offBound = form.subscribe(() => { publish() })
|
|
118
|
+
publish()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The mirror subscription lives for the page: the card owns its form for the
|
|
122
|
+
// session, and the ConfigForm contract carries no disposer to hang it on. The
|
|
123
|
+
// callback is cheap (a resolve plus a no-op while the id is unchanged). A
|
|
124
|
+
// mirror without a subscription seat keeps the initial binding.
|
|
125
|
+
try {
|
|
126
|
+
forms.describe().subscribe(() => { bind() })
|
|
127
|
+
} catch {
|
|
128
|
+
// A describe face that refuses the subscription: the initial binding stands.
|
|
129
|
+
}
|
|
130
|
+
bind()
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
getSnapshot: () => snapshot,
|
|
134
|
+
subscribe: (listener) => {
|
|
135
|
+
listeners.add(listener)
|
|
136
|
+
return () => { listeners.delete(listener) }
|
|
137
|
+
},
|
|
138
|
+
set: (field, value) => bound?.set(field, value) ?? Promise.resolve(false),
|
|
139
|
+
unset: (field) => bound?.unset(field) ?? Promise.resolve(false),
|
|
140
|
+
mutate: (ops, expectedRevision) => bound?.mutate(ops, expectedRevision) ?? Promise.resolve(false),
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/core/adapters.ts
CHANGED
|
@@ -22,6 +22,14 @@ export interface AdapterProbeContext {
|
|
|
22
22
|
apiKey: string
|
|
23
23
|
/** Provider account id riding a dedicated header, when the credential carries one (Codex). */
|
|
24
24
|
accountId?: string
|
|
25
|
+
/**
|
|
26
|
+
* The route's configured base URL (the pi-ai profile's baseURL), when it
|
|
27
|
+
* declares one. An adapter whose endpoint belongs to one provider origin
|
|
28
|
+
* uses it to refuse an account that merely shares the route key, so a
|
|
29
|
+
* differently-hosted model of the same family is never reported with the
|
|
30
|
+
* other account's number.
|
|
31
|
+
*/
|
|
32
|
+
baseURL?: string
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
/** A parsed balance fact. */
|
|
@@ -43,6 +51,14 @@ export interface ProviderAdapter {
|
|
|
43
51
|
/** Fallback display name when the LLM runtime has none. */
|
|
44
52
|
displayName: string
|
|
45
53
|
balance?: {
|
|
54
|
+
/**
|
|
55
|
+
* Origin (scheme + host) whose account this balance endpoint reports.
|
|
56
|
+
* Omitted when the endpoint is per-route by construction (a profile
|
|
57
|
+
* supplies the host) or when the provider has only one origin. When set,
|
|
58
|
+
* a route whose configured baseURL resolves elsewhere is not this
|
|
59
|
+
* origin's account and must not be probed with this adapter.
|
|
60
|
+
*/
|
|
61
|
+
origin?: string
|
|
46
62
|
build(context: AdapterProbeContext): ProbeSpec
|
|
47
63
|
parse(status: number, body: unknown): BalanceParse | undefined
|
|
48
64
|
}
|
|
@@ -113,10 +129,14 @@ function bearer(apiKey: string): Record<string, string> {
|
|
|
113
129
|
* adapter registers (sessions and agent-default-model carry it), so both ids
|
|
114
130
|
* must resolve here or the current provider would never be probed.
|
|
115
131
|
*/
|
|
132
|
+
/** The official DeepSeek API origin: the only account its balance endpoint reports. */
|
|
133
|
+
export const DEEPSEEK_API_ORIGIN = 'https://api.deepseek.com'
|
|
134
|
+
|
|
116
135
|
const DEEPSEEK: ProviderAdapter = {
|
|
117
136
|
ids: ['deepseek', 'deepseek-official'],
|
|
118
137
|
displayName: 'DeepSeek',
|
|
119
138
|
balance: {
|
|
139
|
+
origin: DEEPSEEK_API_ORIGIN,
|
|
120
140
|
build: ({ apiKey }) => ({ url: 'https://api.deepseek.com/user/balance', headers: bearer(apiKey) }),
|
|
121
141
|
parse: (status, body) => {
|
|
122
142
|
if (status !== 200 || typeof body !== 'object' || body === null) return undefined
|
|
@@ -459,6 +479,42 @@ export function adapterFor(provider: string): ProviderAdapter | undefined {
|
|
|
459
479
|
return PROVIDER_ADAPTERS.find((adapter) => adapter.ids.includes(provider))
|
|
460
480
|
}
|
|
461
481
|
|
|
482
|
+
/** Origin of a configured base URL, or undefined when it cannot be parsed. */
|
|
483
|
+
export function originOf(baseURL: string | undefined): string | undefined {
|
|
484
|
+
if (baseURL === undefined || baseURL.trim() === '') return undefined
|
|
485
|
+
try {
|
|
486
|
+
return new URL(baseURL).origin
|
|
487
|
+
} catch {
|
|
488
|
+
return undefined
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Whether an adapter's balance endpoint reports THIS route's account.
|
|
494
|
+
*
|
|
495
|
+
* Some adapter families share one route key across providers that merely
|
|
496
|
+
* speak the same API: `deepseek` is both the official DeepSeek catalog entry
|
|
497
|
+
* and a common id for an OpenAI-compatible reseller (Alibaba Bailian, ...)
|
|
498
|
+
* pointed at another host by the profile's `baseURL`. The balance endpoint is
|
|
499
|
+
* per ORIGIN, not per route key, so probing the official endpoint for a
|
|
500
|
+
* reseller profile reports the official account's money under the reseller's
|
|
501
|
+
* name (issue #1688). An adapter published with a fixed `origin` therefore
|
|
502
|
+
* only applies to a route whose configured base URL is that origin, or to a
|
|
503
|
+
* route that pins no base URL at all (the catalog alias of the official
|
|
504
|
+
* route, which resolves the family's own credential).
|
|
505
|
+
*
|
|
506
|
+
* @param adapter - the matched adapter.
|
|
507
|
+
* @param baseURL - the route's configured base URL (pi-ai profile), if any.
|
|
508
|
+
* @returns true when the adapter may probe its balance endpoint for the route.
|
|
509
|
+
*/
|
|
510
|
+
export function balanceAppliesToRoute(adapter: ProviderAdapter, baseURL: string | undefined): boolean {
|
|
511
|
+
const expected = adapter.balance?.origin
|
|
512
|
+
if (expected === undefined) return true
|
|
513
|
+
const configured = originOf(baseURL)
|
|
514
|
+
if (configured === undefined) return true
|
|
515
|
+
return configured === expected
|
|
516
|
+
}
|
|
517
|
+
|
|
462
518
|
/**
|
|
463
519
|
* Whether a provider route belongs to the official DeepSeek family: the only
|
|
464
520
|
* family with a spend price book and a settings-section-owned env credential
|
|
@@ -15,7 +15,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
15
15
|
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
|
16
16
|
import { dshHome } from '../dsh-home.ts'
|
|
17
17
|
import { withIdentityEncoding } from './http.ts'
|
|
18
|
-
import { adapterFor, isDeepSeekProviderRoute, providerErrorMessage } from '../core/adapters.ts'
|
|
18
|
+
import { adapterFor, balanceAppliesToRoute, isDeepSeekProviderRoute, providerErrorMessage } from '../core/adapters.ts'
|
|
19
19
|
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'
|
|
@@ -202,7 +202,16 @@ export class UsageService {
|
|
|
202
202
|
displayName: route.displayName || adapter?.displayName || route.id,
|
|
203
203
|
credential: snapshot?.credential ?? 'none',
|
|
204
204
|
supported: adapter !== undefined && (adapter.balance !== undefined || adapter.plan !== undefined),
|
|
205
|
-
|
|
205
|
+
// A balance endpoint that belongs to another origin does not report
|
|
206
|
+
// THIS route's account: it is supported by the adapter but not
|
|
207
|
+
// applicable here, and the UI renders it as unsupported rather than
|
|
208
|
+
// showing the other account's number (issue #1688).
|
|
209
|
+
...(adapter?.balance !== undefined && balanceAppliesToRoute(adapter, this.piAiProfile(route.id)?.baseURL)
|
|
210
|
+
? { balanceSupported: true }
|
|
211
|
+
: {}),
|
|
212
|
+
...(adapter?.balance !== undefined && !balanceAppliesToRoute(adapter, this.piAiProfile(route.id)?.baseURL)
|
|
213
|
+
? { balanceSupported: false }
|
|
214
|
+
: {}),
|
|
206
215
|
...(adapter?.plan !== undefined ? { planSupported: true } : {}),
|
|
207
216
|
...(snapshot?.balance !== undefined ? { balance: snapshot.balance } : {}),
|
|
208
217
|
...(snapshot?.plan !== undefined ? { plan: snapshot.plan } : {}),
|
|
@@ -573,6 +582,12 @@ export class UsageService {
|
|
|
573
582
|
private async probeRoute(route: ProviderRoute, adapter: NonNullable<ReturnType<typeof adapterFor>>): Promise<void> {
|
|
574
583
|
const credential = await this.resolveCredential(route.id)
|
|
575
584
|
const previous = this.snapshots.get(route.id)
|
|
585
|
+
// The balance endpoint is per ORIGIN, not per route key: a profile that
|
|
586
|
+
// points a shared route id (`deepseek`) at another host is a different
|
|
587
|
+
// account, and probing the official endpoint would print this account's
|
|
588
|
+
// money under that route's name (issue #1688). Such a route reports the
|
|
589
|
+
// balance as unsupported instead.
|
|
590
|
+
const balanceApplies = balanceAppliesToRoute(adapter, this.piAiProfile(route.id)?.baseURL)
|
|
576
591
|
if (credential.key === undefined) {
|
|
577
592
|
// Nothing to probe with (oauth grant, no credential): retained facts
|
|
578
593
|
// would never refresh again, so the row resets to its credential
|
|
@@ -591,11 +606,13 @@ export class UsageService {
|
|
|
591
606
|
displayName: route.displayName || adapter.displayName,
|
|
592
607
|
credential: credential.kind,
|
|
593
608
|
supported: true,
|
|
609
|
+
...(adapter.balance !== undefined && !balanceApplies ? { balanceSupported: false } : {}),
|
|
594
610
|
updatedAt: Date.now(),
|
|
595
611
|
}
|
|
596
612
|
const runProbe = async (kind: 'balance' | 'plan'): Promise<BalanceView | PlanView | undefined> => {
|
|
597
613
|
const half = adapter[kind]
|
|
598
614
|
if (half === undefined) return undefined
|
|
615
|
+
if (kind === 'balance' && !balanceApplies) return undefined
|
|
599
616
|
try {
|
|
600
617
|
const spec = half.build({ apiKey: credential.key as string, ...(credential.accountId !== undefined ? { accountId: credential.accountId } : {}) })
|
|
601
618
|
const response = await fetch(spec.url, withIdentityEncoding({
|