dsh-plugin-subscription-oauth 0.4.0 → 0.5.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/lib/auth/ccswitch.d.ts +36 -0
- package/lib/auth/ccswitch.js +75 -0
- package/lib/auth/rpc.d.ts +8 -1
- package/lib/auth/rpc.js +29 -17
- package/lib/client/UsageChip.d.ts +70 -2
- package/lib/client/UsageChip.js +116 -1
- package/lib/client/index.d.ts +1 -1
- package/lib/client/index.js +17 -1
- package/lib/client/subscriptions-rpc.d.ts +53 -1
- package/lib/client/subscriptions-rpc.js +56 -0
- package/lib/client.js +241 -2
- package/lib/client.js.map +1 -1
- package/lib/protocol.d.ts +50 -1
- package/lib/protocol.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cc-switch balance query, reimplemented for the plugin's node half.
|
|
3
|
+
*
|
|
4
|
+
* cc-switch (github.com/farion1231/cc-switch) reads a DeepSeek-compatible provider's
|
|
5
|
+
* balance with `GET https://api.deepseek.com/user/balance`, an `Authorization: Bearer
|
|
6
|
+
* <api key>` header, and a 15 s timeout. This is that request, with the URL hardcoded
|
|
7
|
+
* exactly as cc-switch does — the provider's own base URL is never substituted in.
|
|
8
|
+
*
|
|
9
|
+
* The endpoint answers `{ is_available, balance_infos: [{ currency, total_balance, … }] }`,
|
|
10
|
+
* where a balance is a number or a numeric string. The module reduces that to the one
|
|
11
|
+
* figure a status chip needs: the first currency's total. Anything else — a missing key,
|
|
12
|
+
* a non-2xx answer, malformed JSON — resolves to nothing, so the caller shows no balance
|
|
13
|
+
* rather than a wrong one.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-plugin-subscription-oauth/auth/ccswitch
|
|
16
|
+
*/
|
|
17
|
+
import type { FetchFn } from '../http.js';
|
|
18
|
+
/** One currency's balance, normalised from the endpoint's answer. */
|
|
19
|
+
export interface CcSwitchBalance {
|
|
20
|
+
/** The currency the balance is denominated in. */
|
|
21
|
+
currency: string;
|
|
22
|
+
/** The remaining balance, as a decimal string. */
|
|
23
|
+
total: string;
|
|
24
|
+
/** Whether the account reports itself as usable. */
|
|
25
|
+
available: boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Query one provider's balance the way cc-switch does.
|
|
29
|
+
*
|
|
30
|
+
* @param input - the provider's API key, and optionally a fetch to substitute in tests.
|
|
31
|
+
* @returns the balance, or undefined when the provider did not answer with one.
|
|
32
|
+
*/
|
|
33
|
+
export declare function queryCcSwitchBalance(input: {
|
|
34
|
+
apiKey: string;
|
|
35
|
+
fetchFn?: FetchFn;
|
|
36
|
+
}): Promise<CcSwitchBalance | undefined>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cc-switch balance query, reimplemented for the plugin's node half.
|
|
3
|
+
*
|
|
4
|
+
* cc-switch (github.com/farion1231/cc-switch) reads a DeepSeek-compatible provider's
|
|
5
|
+
* balance with `GET https://api.deepseek.com/user/balance`, an `Authorization: Bearer
|
|
6
|
+
* <api key>` header, and a 15 s timeout. This is that request, with the URL hardcoded
|
|
7
|
+
* exactly as cc-switch does — the provider's own base URL is never substituted in.
|
|
8
|
+
*
|
|
9
|
+
* The endpoint answers `{ is_available, balance_infos: [{ currency, total_balance, … }] }`,
|
|
10
|
+
* where a balance is a number or a numeric string. The module reduces that to the one
|
|
11
|
+
* figure a status chip needs: the first currency's total. Anything else — a missing key,
|
|
12
|
+
* a non-2xx answer, malformed JSON — resolves to nothing, so the caller shows no balance
|
|
13
|
+
* rather than a wrong one.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-plugin-subscription-oauth/auth/ccswitch
|
|
16
|
+
*/
|
|
17
|
+
import { proxiedFetch } from '../http.js';
|
|
18
|
+
/** Read a balance field that may be a number or a numeric string. */
|
|
19
|
+
function num(value) {
|
|
20
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
21
|
+
return String(value);
|
|
22
|
+
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)))
|
|
23
|
+
return value.trim();
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Query one provider's balance the way cc-switch does.
|
|
28
|
+
*
|
|
29
|
+
* @param input - the provider's API key, and optionally a fetch to substitute in tests.
|
|
30
|
+
* @returns the balance, or undefined when the provider did not answer with one.
|
|
31
|
+
*/
|
|
32
|
+
export async function queryCcSwitchBalance(input) {
|
|
33
|
+
const fetchFn = input.fetchFn ?? proxiedFetch;
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
// cc-switch's exact request shape: hardcoded URL, bearer auth, 15 s timeout.
|
|
37
|
+
response = await fetchFn('https://api.deepseek.com/user/balance', {
|
|
38
|
+
headers: { authorization: `Bearer ${input.apiKey}`, accept: 'application/json' },
|
|
39
|
+
signal: AbortSignal.timeout(15_000),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
if (!response.ok)
|
|
46
|
+
return undefined;
|
|
47
|
+
let payload;
|
|
48
|
+
try {
|
|
49
|
+
payload = await response.json();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (typeof payload !== 'object' || payload === null)
|
|
55
|
+
return undefined;
|
|
56
|
+
const record = payload;
|
|
57
|
+
const infos = record.balance_infos;
|
|
58
|
+
if (!Array.isArray(infos))
|
|
59
|
+
return undefined;
|
|
60
|
+
for (const raw of infos) {
|
|
61
|
+
if (typeof raw !== 'object' || raw === null)
|
|
62
|
+
continue;
|
|
63
|
+
const info = raw;
|
|
64
|
+
const total = num(info.total_balance);
|
|
65
|
+
if (total === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
const currency = typeof info.currency === 'string' && info.currency.trim() !== '' ? info.currency.trim() : 'CNY';
|
|
68
|
+
return {
|
|
69
|
+
currency,
|
|
70
|
+
total,
|
|
71
|
+
available: record.is_available !== false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
package/lib/auth/rpc.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How one named credential resolves to a value.
|
|
3
|
+
*
|
|
4
|
+
* The host's credential service owns this; the RPC layer only calls it, so the balance
|
|
5
|
+
* query does not re-derive how a key is stored.
|
|
6
|
+
*/
|
|
7
|
+
export type CredentialResolver = (ref: string) => Promise<string | undefined>;
|
|
1
8
|
/**
|
|
2
9
|
* The host-side transport behind the Settings page.
|
|
3
10
|
*
|
|
@@ -21,7 +28,7 @@ import type { SubscriptionRegistry } from './registry.js';
|
|
|
21
28
|
* @param payload - the request's decoded payload.
|
|
22
29
|
* @returns the envelope result.
|
|
23
30
|
*/
|
|
24
|
-
export declare function dispatchRpc(registry: SubscriptionRegistry, endpoint: string, payload: unknown): Promise<RpcResult>;
|
|
31
|
+
export declare function dispatchRpc(registry: SubscriptionRegistry, endpoint: string, payload: unknown, resolveCredential?: CredentialResolver): Promise<RpcResult>;
|
|
25
32
|
/**
|
|
26
33
|
* Register every endpoint route on the `connection` service, when one exists.
|
|
27
34
|
*
|
package/lib/auth/rpc.js
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The host-side transport behind the Settings page.
|
|
3
|
-
*
|
|
4
|
-
* Each endpoint is served as its own `POST /api/subscription-oauth.<endpoint>`
|
|
5
|
-
* Fetch route on the `connection` service. `connection` is deliberately not in
|
|
6
|
-
* the plugin's `inject` list: a headless composition has no connection service,
|
|
7
|
-
* and the provider routes must still work there. Nothing else is registered
|
|
8
|
-
* when it is absent, so the browser half simply finds no routes.
|
|
9
|
-
*
|
|
10
|
-
* @module dsh-plugin-subscription-oauth/auth/rpc
|
|
11
|
-
*/
|
|
12
1
|
import { BAD_REQUEST, ProviderError } from '../errors.js';
|
|
2
|
+
import { queryCcSwitchBalance } from './ccswitch.js';
|
|
13
3
|
import { RPC_ENDPOINTS, RPC_PREFIX } from '../protocol.js';
|
|
14
4
|
/** Wrap a value as a successful envelope result. */
|
|
15
5
|
function success(value) {
|
|
@@ -85,9 +75,9 @@ function requiredProvider(payload, endpoint) {
|
|
|
85
75
|
* @param payload - the request's decoded payload.
|
|
86
76
|
* @returns the envelope result.
|
|
87
77
|
*/
|
|
88
|
-
export async function dispatchRpc(registry, endpoint, payload) {
|
|
78
|
+
export async function dispatchRpc(registry, endpoint, payload, resolveCredential) {
|
|
89
79
|
try {
|
|
90
|
-
return success(await runEndpoint(registry, endpoint, payload));
|
|
80
|
+
return success(await runEndpoint(registry, endpoint, payload, resolveCredential));
|
|
91
81
|
}
|
|
92
82
|
catch (error) {
|
|
93
83
|
return failure(error);
|
|
@@ -97,7 +87,7 @@ export async function dispatchRpc(registry, endpoint, payload) {
|
|
|
97
87
|
* The endpoint table.
|
|
98
88
|
* @throws {ProviderError} for a request the registry refuses.
|
|
99
89
|
*/
|
|
100
|
-
async function runEndpoint(registry, endpoint, payload) {
|
|
90
|
+
async function runEndpoint(registry, endpoint, payload, resolveCredential) {
|
|
101
91
|
switch (endpoint) {
|
|
102
92
|
case 'status':
|
|
103
93
|
return registry.status();
|
|
@@ -145,6 +135,21 @@ async function runEndpoint(registry, endpoint, payload) {
|
|
|
145
135
|
}
|
|
146
136
|
case 'usage':
|
|
147
137
|
return registry.usage(requiredProvider(payload, endpoint), booleanField(payload, 'force'));
|
|
138
|
+
case 'ccswitch-balance': {
|
|
139
|
+
// A manually added provider's balance, by cc-switch's query. The caller names the
|
|
140
|
+
// credential reference the provider's profile uses (its apiKeyEnv), and the host's
|
|
141
|
+
// credential service resolves it. No key, or a key the provider does not answer
|
|
142
|
+
// for, means no balance — never a fabricated zero.
|
|
143
|
+
if (resolveCredential === undefined)
|
|
144
|
+
return undefined;
|
|
145
|
+
const credentialRef = stringField(payload, 'credentialRef');
|
|
146
|
+
if (credentialRef === undefined)
|
|
147
|
+
return undefined;
|
|
148
|
+
const apiKey = await resolveCredential(credentialRef);
|
|
149
|
+
if (apiKey === undefined)
|
|
150
|
+
return undefined;
|
|
151
|
+
return await queryCcSwitchBalance({ apiKey });
|
|
152
|
+
}
|
|
148
153
|
case 'reload':
|
|
149
154
|
return registry.reload();
|
|
150
155
|
default:
|
|
@@ -152,7 +157,7 @@ async function runEndpoint(registry, endpoint, payload) {
|
|
|
152
157
|
}
|
|
153
158
|
}
|
|
154
159
|
/** Build the Fetch route for one endpoint. */
|
|
155
|
-
function rpcRoute(endpoint, registry) {
|
|
160
|
+
function rpcRoute(endpoint, registry, resolveCredential) {
|
|
156
161
|
const method = `${RPC_PREFIX}${endpoint}`;
|
|
157
162
|
return {
|
|
158
163
|
path: `/api/${method}`,
|
|
@@ -176,7 +181,7 @@ function rpcRoute(endpoint, registry) {
|
|
|
176
181
|
if (envelopeRequest.type !== 'client-request' || envelopeRequest.method !== method) {
|
|
177
182
|
return rejection(rpcId, 'gateway/bad-request', `${method} received an envelope for another method`, 400);
|
|
178
183
|
}
|
|
179
|
-
return envelope(rpcId, await dispatchRpc(registry, endpoint, envelopeRequest.payload));
|
|
184
|
+
return envelope(rpcId, await dispatchRpc(registry, endpoint, envelopeRequest.payload, resolveCredential));
|
|
180
185
|
},
|
|
181
186
|
};
|
|
182
187
|
}
|
|
@@ -191,10 +196,17 @@ export function registerSubscriptionRpc(ctx, registry) {
|
|
|
191
196
|
const connection = connectionCtx.get('connection');
|
|
192
197
|
if (connection === undefined)
|
|
193
198
|
return;
|
|
199
|
+
// The cc-switch balance endpoint resolves a credential reference through the host's
|
|
200
|
+
// credential service. It is optional: a composition with no credential service still
|
|
201
|
+
// serves every other endpoint, and the balance endpoint simply answers nothing.
|
|
202
|
+
const credentials = connectionCtx.get('credentials');
|
|
203
|
+
const resolveCredential = credentials?.resolve === undefined
|
|
204
|
+
? undefined
|
|
205
|
+
: async (ref) => (await credentials.resolve?.(ref))?.value;
|
|
194
206
|
const register = (route) => connection.fetch.register(route);
|
|
195
207
|
for (const endpoint of RPC_ENDPOINTS) {
|
|
196
208
|
const method = `${RPC_PREFIX}${endpoint}`;
|
|
197
|
-
connectionCtx.effect(() => register(rpcRoute(endpoint, registry)), `dsh-plugin-subscription-oauth: /api/${method} route`);
|
|
209
|
+
connectionCtx.effect(() => register(rpcRoute(endpoint, registry, resolveCredential)), `dsh-plugin-subscription-oauth: /api/${method} route`);
|
|
198
210
|
}
|
|
199
211
|
});
|
|
200
212
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { ReactNode } from 'react';
|
|
19
19
|
import type { SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots';
|
|
20
|
-
import type { SubscriptionsCall } from './subscriptions-rpc.js';
|
|
20
|
+
import type { SubscriptionsCall, AccountBalanceCall, CcSwitchBalanceCall } from './subscriptions-rpc.js';
|
|
21
21
|
import type { ProviderUsage, UsageWindow } from '../protocol.js';
|
|
22
22
|
/** Injected props the chip is registered with. */
|
|
23
23
|
export interface UsageChipInjected {
|
|
@@ -27,6 +27,34 @@ export interface UsageChipInjected {
|
|
|
27
27
|
t: (key: 'usageChipTitle' | 'usageChipFull', values?: Record<string, string | number>) => string;
|
|
28
28
|
/** Whether the provider is one this plugin serves. */
|
|
29
29
|
isOwnProvider: (provider: string) => boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The DeepSeek account's balance, when the session uses it.
|
|
32
|
+
*
|
|
33
|
+
* The host owns that account's grant and the platform endpoint, so the chip asks the
|
|
34
|
+
* host rather than re-deriving either. It resolves to `undefined` when the account is
|
|
35
|
+
* signed out or the read fails, in which case the chip stays silent.
|
|
36
|
+
*/
|
|
37
|
+
accountBalance: AccountBalanceCall;
|
|
38
|
+
/**
|
|
39
|
+
* A manually added provider's balance, by cc-switch's query.
|
|
40
|
+
*
|
|
41
|
+
* The caller names the credential reference the provider's profile uses (its
|
|
42
|
+
* `apiKeyEnv`); the host's credential service resolves it, and the node half asks
|
|
43
|
+
* cc-switch's balance endpoint. It resolves to `undefined` when there is no key or
|
|
44
|
+
* the provider did not answer.
|
|
45
|
+
*/
|
|
46
|
+
ccSwitchBalance: CcSwitchBalanceCall;
|
|
47
|
+
/**
|
|
48
|
+
* The credential reference a manually added provider's balance query uses, by provider
|
|
49
|
+
* id.
|
|
50
|
+
*
|
|
51
|
+
* A manually added provider is not one this plugin serves and has no account route, so
|
|
52
|
+
* the only way to read its balance is cc-switch's query, which needs the provider's API
|
|
53
|
+
* key. That key is named by the provider's profile (`apiKeyEnv`), and the host's
|
|
54
|
+
* credential service resolves the name; this map is the one place a provider id is tied
|
|
55
|
+
* to that name. A provider with no entry gets no balance.
|
|
56
|
+
*/
|
|
57
|
+
ccSwitchCredentialRef: (provider: string) => string | undefined;
|
|
30
58
|
}
|
|
31
59
|
/** One model choice, as the host records it. */
|
|
32
60
|
export interface ModelSelection {
|
|
@@ -65,6 +93,46 @@ export interface ChipReading {
|
|
|
65
93
|
/** Plan name, when the provider stated one. */
|
|
66
94
|
plan?: string;
|
|
67
95
|
}
|
|
96
|
+
/** One currency's balance, as the host's account controller reports it. */
|
|
97
|
+
export interface ChipBalance {
|
|
98
|
+
/** The wallet's currency. */
|
|
99
|
+
currency: string;
|
|
100
|
+
/** Remaining balance, as a decimal string. */
|
|
101
|
+
balance: string;
|
|
102
|
+
/** Whether this is a bonus / promotional wallet rather than topped-up credit. */
|
|
103
|
+
bonus: boolean;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Reduce the host's account-balance payload to the wallets the chip shows.
|
|
107
|
+
*
|
|
108
|
+
* Bonus wallets are shown after the topped-up ones, so a reader sees their own credit
|
|
109
|
+
* first and any promotional credit as a secondary figure.
|
|
110
|
+
*
|
|
111
|
+
* @param value - the host's ready balance, or undefined when there is none.
|
|
112
|
+
* @returns the wallets, topped-up first then bonus, or undefined when nothing is usable.
|
|
113
|
+
*/
|
|
114
|
+
export declare function balanceOf(value: {
|
|
115
|
+
value?: readonly {
|
|
116
|
+
currency: string;
|
|
117
|
+
balance: string;
|
|
118
|
+
}[];
|
|
119
|
+
bonusWallets?: readonly {
|
|
120
|
+
currency: string;
|
|
121
|
+
balance: string;
|
|
122
|
+
}[];
|
|
123
|
+
} | {
|
|
124
|
+
status: string;
|
|
125
|
+
} | null | undefined): readonly ChipBalance[] | undefined;
|
|
126
|
+
/**
|
|
127
|
+
* Format one wallet for the chip's compact line.
|
|
128
|
+
*
|
|
129
|
+
* A balance is money, not a percentage, so it renders as `¥6.89` rather than a meter;
|
|
130
|
+
* the currency prefix keeps two currencies from being mistaken for one sum.
|
|
131
|
+
*
|
|
132
|
+
* @param wallet - the wallet to show.
|
|
133
|
+
* @returns the short form, e.g. `¥6.89` or `+¥2` for a bonus wallet.
|
|
134
|
+
*/
|
|
135
|
+
export declare function formatBalance(wallet: ChipBalance): string;
|
|
68
136
|
/**
|
|
69
137
|
* The short form of a route's name.
|
|
70
138
|
*
|
|
@@ -117,4 +185,4 @@ export declare function formatReset(at: number | undefined, now: number): string
|
|
|
117
185
|
* @param props - injected RPC caller, copy, and the session's provider reader.
|
|
118
186
|
* @returns the chip, or null when there is nothing true to say.
|
|
119
187
|
*/
|
|
120
|
-
export declare function UsageChip({ call, t, isOwnProvider, useProjection }: UsageChipInjected & SessionStandardProps): ReactNode;
|
|
188
|
+
export declare function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, ccSwitchCredentialRef, useProjection }: UsageChipInjected & SessionStandardProps): ReactNode;
|
package/lib/client/UsageChip.js
CHANGED
|
@@ -55,6 +55,41 @@ const SHORT_WINDOWS = {
|
|
|
55
55
|
export function providerOf(projection) {
|
|
56
56
|
return (projection?.next ?? projection?.lastUsed)?.provider;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Reduce the host's account-balance payload to the wallets the chip shows.
|
|
60
|
+
*
|
|
61
|
+
* Bonus wallets are shown after the topped-up ones, so a reader sees their own credit
|
|
62
|
+
* first and any promotional credit as a secondary figure.
|
|
63
|
+
*
|
|
64
|
+
* @param value - the host's ready balance, or undefined when there is none.
|
|
65
|
+
* @returns the wallets, topped-up first then bonus, or undefined when nothing is usable.
|
|
66
|
+
*/
|
|
67
|
+
export function balanceOf(value) {
|
|
68
|
+
// A failed read or no account (`{status:'failed'}` or `null`) reduces to nothing, as
|
|
69
|
+
// does a ready payload that names no wallet.
|
|
70
|
+
if (value === null || value === undefined || !('value' in value) || value.value === undefined)
|
|
71
|
+
return undefined;
|
|
72
|
+
const wallets = [
|
|
73
|
+
...value.value.map((wallet) => ({ currency: wallet.currency, balance: wallet.balance, bonus: false })),
|
|
74
|
+
...(value.bonusWallets ?? []).map((wallet) => ({ currency: wallet.currency, balance: wallet.balance, bonus: true })),
|
|
75
|
+
];
|
|
76
|
+
return wallets.length === 0 ? undefined : wallets;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Format one wallet for the chip's compact line.
|
|
80
|
+
*
|
|
81
|
+
* A balance is money, not a percentage, so it renders as `¥6.89` rather than a meter;
|
|
82
|
+
* the currency prefix keeps two currencies from being mistaken for one sum.
|
|
83
|
+
*
|
|
84
|
+
* @param wallet - the wallet to show.
|
|
85
|
+
* @returns the short form, e.g. `¥6.89` or `+¥2` for a bonus wallet.
|
|
86
|
+
*/
|
|
87
|
+
export function formatBalance(wallet) {
|
|
88
|
+
const amount = Number.parseFloat(wallet.balance);
|
|
89
|
+
const trimmed = Number.isFinite(amount) ? String(Math.round(amount * 100) / 100) : wallet.balance;
|
|
90
|
+
const symbol = wallet.currency === 'CNY' ? '¥' : wallet.currency === 'USD' ? '$' : `${wallet.currency} `;
|
|
91
|
+
return `${wallet.bonus ? '+' : ''}${symbol}${trimmed}`;
|
|
92
|
+
}
|
|
58
93
|
/**
|
|
59
94
|
* The short form of a route's name.
|
|
60
95
|
*
|
|
@@ -157,8 +192,10 @@ export function formatReset(at, now) {
|
|
|
157
192
|
* @param props - injected RPC caller, copy, and the session's provider reader.
|
|
158
193
|
* @returns the chip, or null when there is nothing true to say.
|
|
159
194
|
*/
|
|
160
|
-
export function UsageChip({ call, t, isOwnProvider, useProjection }) {
|
|
195
|
+
export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, ccSwitchCredentialRef, useProjection }) {
|
|
161
196
|
const [reading, setReading] = useState(undefined);
|
|
197
|
+
const [balance, setBalance] = useState(undefined);
|
|
198
|
+
const [ccBalance, setCcBalance] = useState(undefined);
|
|
162
199
|
const [now, setNow] = useState(() => Date.now());
|
|
163
200
|
const [open, setOpen] = useState(false);
|
|
164
201
|
// The last route that answered, so a switch to a foreign provider leaves the last own
|
|
@@ -167,6 +204,39 @@ export function UsageChip({ call, t, isOwnProvider, useProjection }) {
|
|
|
167
204
|
// A projection read, so the chip follows the picker: changing the model re-renders it
|
|
168
205
|
// with the new provider instead of waiting for the next unrelated render.
|
|
169
206
|
const selected = useProjection('modelSelection', (value) => providerOf(value));
|
|
207
|
+
// The DeepSeek account is not one of this plugin's routes, but the host owns its grant
|
|
208
|
+
// and the balance endpoint, so the chip shows its balance rather than staying silent.
|
|
209
|
+
// An own route takes the balance read over nothing.
|
|
210
|
+
const isDeepSeek = selected === 'deepseek-account';
|
|
211
|
+
// A manually added provider reads its balance by cc-switch's query, which needs the
|
|
212
|
+
// provider's API key. The provider's profile names that key by reference, and this map
|
|
213
|
+
// is the one place a provider id is tied to the reference; a provider with no entry,
|
|
214
|
+
// or one whose key does not resolve, gets no balance.
|
|
215
|
+
const ccRef = selected === undefined || isOwnProvider(selected) || isDeepSeek
|
|
216
|
+
? undefined
|
|
217
|
+
: ccSwitchCredentialRef(selected);
|
|
218
|
+
// Read a manually added provider's balance while it is in force, on the same cadence as
|
|
219
|
+
// the other reads. It resolves to undefined when there is no key or the provider did
|
|
220
|
+
// not answer, which leaves the chip silent.
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (ccRef === undefined) {
|
|
223
|
+
setCcBalance(undefined);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
let live = true;
|
|
227
|
+
const refresh = () => {
|
|
228
|
+
void ccSwitchBalance(ccRef)
|
|
229
|
+
.then((value) => { if (live)
|
|
230
|
+
setCcBalance(value === undefined ? undefined : { currency: value.currency, total: value.total }); })
|
|
231
|
+
.catch(() => { });
|
|
232
|
+
};
|
|
233
|
+
refresh();
|
|
234
|
+
const timer = setInterval(refresh, REFRESH_INTERVAL_MS);
|
|
235
|
+
return () => {
|
|
236
|
+
live = false;
|
|
237
|
+
clearInterval(timer);
|
|
238
|
+
};
|
|
239
|
+
}, [ccRef, ccSwitchBalance]);
|
|
170
240
|
const load = useCallback(async (target) => {
|
|
171
241
|
try {
|
|
172
242
|
const result = await call('usage', { provider: target });
|
|
@@ -179,6 +249,28 @@ export function UsageChip({ call, t, isOwnProvider, useProjection }) {
|
|
|
179
249
|
return undefined;
|
|
180
250
|
}
|
|
181
251
|
}, [call]);
|
|
252
|
+
// Read the DeepSeek account's balance while that provider is in force, on the same
|
|
253
|
+
// cadence as a usage read. It resolves to undefined when the account is signed out or
|
|
254
|
+
// the read fails, which leaves the chip silent.
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (!isDeepSeek) {
|
|
257
|
+
setBalance(undefined);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
let live = true;
|
|
261
|
+
const refresh = () => {
|
|
262
|
+
void accountBalance()
|
|
263
|
+
.then((value) => { if (live)
|
|
264
|
+
setBalance(balanceOf(value)); })
|
|
265
|
+
.catch(() => { });
|
|
266
|
+
};
|
|
267
|
+
refresh();
|
|
268
|
+
const timer = setInterval(refresh, REFRESH_INTERVAL_MS);
|
|
269
|
+
return () => {
|
|
270
|
+
live = false;
|
|
271
|
+
clearInterval(timer);
|
|
272
|
+
};
|
|
273
|
+
}, [isDeepSeek, accountBalance]);
|
|
182
274
|
useEffect(() => {
|
|
183
275
|
const target = selected !== undefined && isOwnProvider(selected)
|
|
184
276
|
? selected
|
|
@@ -213,6 +305,29 @@ export function UsageChip({ call, t, isOwnProvider, useProjection }) {
|
|
|
213
305
|
document.addEventListener('click', close);
|
|
214
306
|
return () => { document.removeEventListener('click', close); };
|
|
215
307
|
}, [open]);
|
|
308
|
+
// A manually added provider reads as money too, through cc-switch's query. It shows
|
|
309
|
+
// the one currency's total the provider answered with, nothing when there is no usable
|
|
310
|
+
// balance.
|
|
311
|
+
if (ccRef !== undefined) {
|
|
312
|
+
if (ccBalance === undefined)
|
|
313
|
+
return null;
|
|
314
|
+
const short = shortLabel(selected ?? '', selected ?? '');
|
|
315
|
+
return (_jsxs("span", { className: "dsh-subscription-usage-chip", children: [_jsx("style", { children: usageChipCss }), _jsxs("button", { type: "button", className: "dsh-subscription-usage-chip-trigger", "aria-expanded": open, title: t('usageChipTitle', { provider: selected ?? '' }), onClick: (event) => {
|
|
316
|
+
event.stopPropagation();
|
|
317
|
+
setOpen((was) => !was);
|
|
318
|
+
}, children: [_jsx("span", { className: "dsh-subscription-usage-chip-label", children: short }), _jsx("span", { className: "dsh-subscription-usage-chip-cell", children: _jsx("span", { className: "dsh-subscription-usage-chip-value", children: `${ccBalance.currency === 'CNY' ? '¥' : ccBalance.currency === 'USD' ? '$' : `${ccBalance.currency} `}${ccBalance.total}` }) })] }), open && (_jsxs("span", { className: "dsh-subscription-usage-chip-panel", role: "status", children: [_jsx("span", { className: "dsh-subscription-usage-chip-full", children: t('usageChipFull', { provider: selected ?? '' }) }), _jsx("span", { className: "dsh-subscription-usage-chip-meter", children: _jsxs("span", { className: "dsh-subscription-usage-chip-meterHead", children: [_jsx("span", { children: ccBalance.currency }), _jsx("span", { className: "dsh-subscription-usage-chip-detail", children: ccBalance.total })] }) })] }))] }));
|
|
319
|
+
}
|
|
320
|
+
// The DeepSeek account reads as money, not as a percentage, so it takes its own line:
|
|
321
|
+
// the route abbreviated, then each wallet. An unknown selection leaves the chip silent.
|
|
322
|
+
if (isDeepSeek) {
|
|
323
|
+
if (balance === undefined)
|
|
324
|
+
return null;
|
|
325
|
+
const short = shortLabel('deepseek-account', 'DeepSeek Account');
|
|
326
|
+
return (_jsxs("span", { className: "dsh-subscription-usage-chip", children: [_jsx("style", { children: usageChipCss }), _jsxs("button", { type: "button", className: "dsh-subscription-usage-chip-trigger", "aria-expanded": open, title: t('usageChipTitle', { provider: 'DeepSeek Account' }), onClick: (event) => {
|
|
327
|
+
event.stopPropagation();
|
|
328
|
+
setOpen((was) => !was);
|
|
329
|
+
}, children: [_jsx("span", { className: "dsh-subscription-usage-chip-label", children: short }), balance.map((wallet) => (_jsx("span", { className: "dsh-subscription-usage-chip-cell", children: _jsx("span", { className: "dsh-subscription-usage-chip-value", children: formatBalance(wallet) }) }, `${wallet.currency}${wallet.bonus ? ':bonus' : ''}`)))] }), open && (_jsxs("span", { className: "dsh-subscription-usage-chip-panel", role: "status", children: [_jsx("span", { className: "dsh-subscription-usage-chip-full", children: t('usageChipFull', { provider: 'DeepSeek Account' }) }), balance.map((wallet) => (_jsx("span", { className: "dsh-subscription-usage-chip-meter", children: _jsxs("span", { className: "dsh-subscription-usage-chip-meterHead", children: [_jsx("span", { children: `${wallet.bonus ? 'Bonus · ' : ''}${wallet.currency}` }), _jsx("span", { className: "dsh-subscription-usage-chip-detail", children: formatBalance(wallet) })] }) }, `${wallet.currency}${wallet.bonus ? ':bonus' : ''}`)))] }))] }));
|
|
330
|
+
}
|
|
216
331
|
if (reading === undefined)
|
|
217
332
|
return null;
|
|
218
333
|
const percent = reading.window.usedPercent;
|
package/lib/client/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type { SubscriptionsSectionInjected, SubscriptionsSectionProps } from './
|
|
|
14
14
|
export type { UsageChipInjected } from './UsageChip.js';
|
|
15
15
|
export type { SubscriptionsKey } from './locales.js';
|
|
16
16
|
export { createSubscriptionsCall, SubscriptionsRpcError } from './subscriptions-rpc.js';
|
|
17
|
-
export type { SubscriptionsCall } from './subscriptions-rpc.js';
|
|
17
|
+
export type { SubscriptionsCall, AccountBalanceCall } from './subscriptions-rpc.js';
|
|
18
18
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
19
19
|
interface LocaleNamespaceMap {
|
|
20
20
|
/** The Subscriptions settings page copy. */
|
package/lib/client/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { SubscriptionsSection } from './SubscriptionsSection.js';
|
|
12
12
|
import { UsageChip } from './UsageChip.js';
|
|
13
|
-
import { createSubscriptionsCall } from './subscriptions-rpc.js';
|
|
13
|
+
import { createSubscriptionsCall, createAccountBalanceCall, createCcSwitchBalanceCall } from './subscriptions-rpc.js';
|
|
14
14
|
import { en, zh } from './locales.js';
|
|
15
15
|
export { createSubscriptionsCall, SubscriptionsRpcError } from './subscriptions-rpc.js';
|
|
16
16
|
/**
|
|
@@ -31,6 +31,19 @@ const SECTION_ORDER = 92;
|
|
|
31
31
|
* chip follows them rather than displacing them.
|
|
32
32
|
*/
|
|
33
33
|
const CHIP_ORDER = 10;
|
|
34
|
+
/**
|
|
35
|
+
* The credential reference a manually added provider's balance query uses, by provider
|
|
36
|
+
* id.
|
|
37
|
+
*
|
|
38
|
+
* A manually added provider is not one this plugin serves, so the only way to read its
|
|
39
|
+
* balance is cc-switch's query, which needs the provider's API key. The provider's
|
|
40
|
+
* profile names that key by reference (`apiKeyEnv`), and the host's credential service
|
|
41
|
+
* resolves the name; this map is the one place a provider id is tied to that name. A
|
|
42
|
+
* provider with no entry gets no balance.
|
|
43
|
+
*/
|
|
44
|
+
const CC_SWITCH_CREDENTIAL_REFS = {
|
|
45
|
+
huanling: 'HUANLING_API_KEY',
|
|
46
|
+
};
|
|
34
47
|
/**
|
|
35
48
|
* Required services: `slots` carries the registration seat, `connection` the
|
|
36
49
|
* RPC caller, and `locale` the copy dictionaries.
|
|
@@ -71,6 +84,9 @@ export function apply(ctx) {
|
|
|
71
84
|
call,
|
|
72
85
|
t: t,
|
|
73
86
|
isOwnProvider: (provider) => OWN_PROVIDER_IDS.has(provider),
|
|
87
|
+
accountBalance: createAccountBalanceCall(connection.rpc),
|
|
88
|
+
ccSwitchBalance: createCcSwitchBalanceCall(connection.rpc),
|
|
89
|
+
ccSwitchCredentialRef: (provider) => CC_SWITCH_CREDENTIAL_REFS[provider],
|
|
74
90
|
}),
|
|
75
91
|
}, UsageChip));
|
|
76
92
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @module dsh-plugin-subscription-oauth/client/subscriptions-rpc
|
|
9
9
|
*/
|
|
10
10
|
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
|
|
11
|
-
import type { RpcEndpoint, RpcMap } from '../protocol.js';
|
|
11
|
+
import type { CcSwitchBalanceResult, RpcEndpoint, RpcMap } from '../protocol.js';
|
|
12
12
|
/** A failed call: either the transport rejected or the endpoint answered `ok: false`. */
|
|
13
13
|
export declare class SubscriptionsRpcError extends Error {
|
|
14
14
|
readonly code: string;
|
|
@@ -16,9 +16,61 @@ export declare class SubscriptionsRpcError extends Error {
|
|
|
16
16
|
}
|
|
17
17
|
/** Call one endpoint, unwrapping the transport result. */
|
|
18
18
|
export type SubscriptionsCall = <K extends RpcEndpoint>(endpoint: K, payload: RpcMap[K]['payload']) => Promise<RpcMap[K]['value']>;
|
|
19
|
+
/** A caller for one manually added provider's cc-switch balance. */
|
|
20
|
+
export interface CcSwitchBalanceCall {
|
|
21
|
+
(credentialRef: string): Promise<CcSwitchBalanceResult>;
|
|
22
|
+
}
|
|
19
23
|
/**
|
|
20
24
|
* Bind a caller to one connection's RPC transport.
|
|
21
25
|
* @param rpc - the browser connection's RPC caller.
|
|
22
26
|
* @returns a typed caller for this plugin's endpoints.
|
|
23
27
|
*/
|
|
24
28
|
export declare function createSubscriptionsCall(rpc: ConnectionHandle['rpc']): SubscriptionsCall;
|
|
29
|
+
/**
|
|
30
|
+
* The DeepSeek account's wallet balances, as the host's account controller reports them.
|
|
31
|
+
*
|
|
32
|
+
* The controller answers `null` when the account is signed out, `{ status: 'failed' }`
|
|
33
|
+
* when the read did not complete, and `{ status: 'ready', value: <normal wallets>,
|
|
34
|
+
* bonusWallets: <bonus wallets> }` on success.
|
|
35
|
+
*/
|
|
36
|
+
export interface HostAccountBalance {
|
|
37
|
+
/** Normal (topped-up) wallets, one per currency. */
|
|
38
|
+
value: readonly {
|
|
39
|
+
currency: string;
|
|
40
|
+
balance: string;
|
|
41
|
+
}[];
|
|
42
|
+
/** Bonus / promotional wallets, one per currency. */
|
|
43
|
+
bonusWallets?: readonly {
|
|
44
|
+
currency: string;
|
|
45
|
+
balance: string;
|
|
46
|
+
}[];
|
|
47
|
+
}
|
|
48
|
+
/** A narrow caller for the host's own account controller. */
|
|
49
|
+
export interface AccountBalanceCall {
|
|
50
|
+
(): Promise<HostAccountBalance | {
|
|
51
|
+
status: 'failed';
|
|
52
|
+
} | null>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Read the DeepSeek account's balance through the host's own account controller.
|
|
56
|
+
*
|
|
57
|
+
* The host owns the DeepSeek OAuth grant and the platform endpoint, so this asks it
|
|
58
|
+
* rather than re-deriving either. It returns `null` when the account is signed out or
|
|
59
|
+
* the read fails, so a chip can simply not render in that case.
|
|
60
|
+
*
|
|
61
|
+
* @param rpc - the browser connection's RPC caller.
|
|
62
|
+
* @returns the balance, or null when there is nothing to show.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createAccountBalanceCall(rpc: ConnectionHandle['rpc']): AccountBalanceCall;
|
|
65
|
+
/**
|
|
66
|
+
* Read a manually added provider's balance through the plugin's cc-switch endpoint.
|
|
67
|
+
*
|
|
68
|
+
* The caller names the credential reference the provider's profile uses (its
|
|
69
|
+
* `apiKeyEnv`), and the host's credential service resolves it to the API key. The result
|
|
70
|
+
* is `undefined` when there is no key or the provider did not answer — never a wrong
|
|
71
|
+
* figure.
|
|
72
|
+
*
|
|
73
|
+
* @param rpc - the browser connection's RPC caller.
|
|
74
|
+
* @returns a narrow caller taking the credential reference.
|
|
75
|
+
*/
|
|
76
|
+
export declare function createCcSwitchBalanceCall(rpc: ConnectionHandle['rpc']): CcSwitchBalanceCall;
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
import { RPC_PREFIX } from '../protocol.js';
|
|
11
11
|
/** The shared channel the node half's routes are mounted on. */
|
|
12
12
|
const CHANNEL = '/api';
|
|
13
|
+
/** The host's own DeepSeek account controller, from which the chip reads a balance. */
|
|
14
|
+
const ACCOUNT_CHANNEL = '/api';
|
|
15
|
+
const ACCOUNT_NAMESPACE = 'account';
|
|
13
16
|
/** A failed call: either the transport rejected or the endpoint answered `ok: false`. */
|
|
14
17
|
export class SubscriptionsRpcError extends Error {
|
|
15
18
|
code;
|
|
@@ -42,3 +45,56 @@ export function createSubscriptionsCall(rpc) {
|
|
|
42
45
|
return result.value;
|
|
43
46
|
};
|
|
44
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Read the DeepSeek account's balance through the host's own account controller.
|
|
50
|
+
*
|
|
51
|
+
* The host owns the DeepSeek OAuth grant and the platform endpoint, so this asks it
|
|
52
|
+
* rather than re-deriving either. It returns `null` when the account is signed out or
|
|
53
|
+
* the read fails, so a chip can simply not render in that case.
|
|
54
|
+
*
|
|
55
|
+
* @param rpc - the browser connection's RPC caller.
|
|
56
|
+
* @returns the balance, or null when there is nothing to show.
|
|
57
|
+
*/
|
|
58
|
+
export function createAccountBalanceCall(rpc) {
|
|
59
|
+
return async function call() {
|
|
60
|
+
let result;
|
|
61
|
+
try {
|
|
62
|
+
result = await rpc.call(ACCOUNT_CHANNEL, 'account/getBalance', {
|
|
63
|
+
version: '1',
|
|
64
|
+
locale: Intl.DateTimeFormat().resolvedOptions().locale ?? 'en-US',
|
|
65
|
+
timezoneOffsetSeconds: -new Date().getTimezoneOffset(),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw new SubscriptionsRpcError(messageOf(error), 'RPC_TRANSPORT');
|
|
70
|
+
}
|
|
71
|
+
if (!result.ok)
|
|
72
|
+
throw new SubscriptionsRpcError(result.error.message, result.error.code);
|
|
73
|
+
return result.value;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Read a manually added provider's balance through the plugin's cc-switch endpoint.
|
|
78
|
+
*
|
|
79
|
+
* The caller names the credential reference the provider's profile uses (its
|
|
80
|
+
* `apiKeyEnv`), and the host's credential service resolves it to the API key. The result
|
|
81
|
+
* is `undefined` when there is no key or the provider did not answer — never a wrong
|
|
82
|
+
* figure.
|
|
83
|
+
*
|
|
84
|
+
* @param rpc - the browser connection's RPC caller.
|
|
85
|
+
* @returns a narrow caller taking the credential reference.
|
|
86
|
+
*/
|
|
87
|
+
export function createCcSwitchBalanceCall(rpc) {
|
|
88
|
+
return async function call(credentialRef) {
|
|
89
|
+
let result;
|
|
90
|
+
try {
|
|
91
|
+
result = await rpc.call(CHANNEL, `${RPC_PREFIX}ccswitch-balance`, { credentialRef });
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new SubscriptionsRpcError(messageOf(error), 'RPC_TRANSPORT');
|
|
95
|
+
}
|
|
96
|
+
if (!result.ok)
|
|
97
|
+
throw new SubscriptionsRpcError(result.error.message, result.error.code);
|
|
98
|
+
return result.value;
|
|
99
|
+
};
|
|
100
|
+
}
|