dsh-plugin-subscription-oauth 0.6.1 → 0.7.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.
@@ -24,13 +24,38 @@ export interface CcSwitchBalance {
24
24
  /** Whether the account reports itself as usable. */
25
25
  available: boolean;
26
26
  }
27
+ /**
28
+ * Query the balance endpoint some OpenAI-compatible providers expose at `{base}/v1/usage`.
29
+ *
30
+ * huanling is one: it answers `GET {base}/v1/usage` with `{ remaining, unit, … }`, which is
31
+ * the figure cc-switch shows for it. This is the provider's own shape, not cc-switch's
32
+ * `balance_infos`, so it is read separately. The base has any trailing `/v1` stripped before
33
+ * the path is added, so both `https://api.huanling.icu` and `https://api.huanling.icu/v1`
34
+ * resolve to the same `{base}/v1/usage`.
35
+ *
36
+ * @param input - the provider's API key and base URL.
37
+ * @returns the balance, or undefined when the provider did not answer with one.
38
+ */
39
+ export declare function queryUsageBalance(input: {
40
+ apiKey: string;
41
+ baseUrl: string;
42
+ fetchFn?: FetchFn;
43
+ }): Promise<CcSwitchBalance | undefined>;
27
44
  /**
28
45
  * Query one provider's balance the way cc-switch does.
29
46
  *
30
- * @param input - the provider's API key, and optionally a fetch to substitute in tests.
47
+ * cc-switch reads a DeepSeek-compatible provider's balance with `GET <base>/user/balance`
48
+ * and a bearer API key. For the official DeepSeek that base is `api.deepseek.com`, so the
49
+ * path is effectively hardcoded there; for a provider the user added through a custom
50
+ * endpoint (like huanling), the same path is read against that provider's own base URL,
51
+ * which is the only place its balance can live.
52
+ *
53
+ * @param input - the provider's API key and base URL.
31
54
  * @returns the balance, or undefined when the provider did not answer with one.
32
55
  */
33
56
  export declare function queryCcSwitchBalance(input: {
34
57
  apiKey: string;
58
+ /** The provider's own base URL; `https://api.deepseek.com` for the official one. */
59
+ baseUrl: string;
35
60
  fetchFn?: FetchFn;
36
61
  }): Promise<CcSwitchBalance | undefined>;
@@ -23,18 +23,75 @@ function num(value) {
23
23
  return value.trim();
24
24
  return undefined;
25
25
  }
26
+ /**
27
+ * Query the balance endpoint some OpenAI-compatible providers expose at `{base}/v1/usage`.
28
+ *
29
+ * huanling is one: it answers `GET {base}/v1/usage` with `{ remaining, unit, … }`, which is
30
+ * the figure cc-switch shows for it. This is the provider's own shape, not cc-switch's
31
+ * `balance_infos`, so it is read separately. The base has any trailing `/v1` stripped before
32
+ * the path is added, so both `https://api.huanling.icu` and `https://api.huanling.icu/v1`
33
+ * resolve to the same `{base}/v1/usage`.
34
+ *
35
+ * @param input - the provider's API key and base URL.
36
+ * @returns the balance, or undefined when the provider did not answer with one.
37
+ */
38
+ export async function queryUsageBalance(input) {
39
+ const fetchFn = input.fetchFn ?? proxiedFetch;
40
+ const base = input.baseUrl.replace(/\/+$/, '').replace(/\/v1$/i, '');
41
+ const url = `${base}/v1/usage`;
42
+ let response;
43
+ try {
44
+ response = await fetchFn(url, {
45
+ headers: { authorization: `Bearer ${input.apiKey}`, accept: 'application/json' },
46
+ signal: AbortSignal.timeout(15_000),
47
+ });
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ if (!response.ok)
53
+ return undefined;
54
+ let payload;
55
+ try {
56
+ payload = await response.json();
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ if (typeof payload !== 'object' || payload === null)
62
+ return undefined;
63
+ const record = payload;
64
+ const total = num(record.remaining);
65
+ if (total === undefined)
66
+ return undefined;
67
+ const currency = typeof record.unit === 'string' && record.unit.trim() !== '' ? record.unit.trim() : 'USD';
68
+ return {
69
+ currency,
70
+ total,
71
+ available: record.isValid !== false && record.is_active !== false,
72
+ };
73
+ }
26
74
  /**
27
75
  * Query one provider's balance the way cc-switch does.
28
76
  *
29
- * @param input - the provider's API key, and optionally a fetch to substitute in tests.
77
+ * cc-switch reads a DeepSeek-compatible provider's balance with `GET <base>/user/balance`
78
+ * and a bearer API key. For the official DeepSeek that base is `api.deepseek.com`, so the
79
+ * path is effectively hardcoded there; for a provider the user added through a custom
80
+ * endpoint (like huanling), the same path is read against that provider's own base URL,
81
+ * which is the only place its balance can live.
82
+ *
83
+ * @param input - the provider's API key and base URL.
30
84
  * @returns the balance, or undefined when the provider did not answer with one.
31
85
  */
32
86
  export async function queryCcSwitchBalance(input) {
33
87
  const fetchFn = input.fetchFn ?? proxiedFetch;
88
+ // Resolve `<base>/user/balance` without doubling or dropping the path: a base that
89
+ // already ends in `/v1` keeps it, since that is the path the OpenAI-compatible wire
90
+ // uses and the balance endpoint lives beside it.
91
+ const url = `${input.baseUrl.replace(/\/+$/, '')}/user/balance`;
34
92
  let response;
35
93
  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', {
94
+ response = await fetchFn(url, {
38
95
  headers: { authorization: `Bearer ${input.apiKey}`, accept: 'application/json' },
39
96
  signal: AbortSignal.timeout(15_000),
40
97
  });
package/lib/auth/rpc.d.ts CHANGED
@@ -16,6 +16,8 @@ export type CredentialResolver = (ref: string) => Promise<string | undefined>;
16
16
  export interface BalanceContext {
17
17
  /** The credential reference each provider id's balance query uses. */
18
18
  readonly credentialRefs: Readonly<Record<string, string>>;
19
+ /** The base URL each provider id's balance query reads, when it is not DeepSeek's own. */
20
+ readonly baseUrls: Readonly<Record<string, string>>;
19
21
  /** Resolve one credential reference to its key. */
20
22
  readonly resolveCredential?: CredentialResolver;
21
23
  /**
package/lib/auth/rpc.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { BAD_REQUEST, ProviderError } from '../errors.js';
2
- import { queryCcSwitchBalance } from './ccswitch.js';
2
+ import { queryCcSwitchBalance, queryUsageBalance } from './ccswitch.js';
3
3
  import { readDeepSeekBalance } from './deepseek-balance.js';
4
4
  import { RPC_ENDPOINTS, RPC_PREFIX } from '../protocol.js';
5
5
  /** Wrap a value as a successful envelope result. */
@@ -165,11 +165,12 @@ async function runEndpoint(registry, endpoint, payload, balance) {
165
165
  return await readDeepSeekBalance(balance?.resolveDeepSeekAccount?.(), client);
166
166
  }
167
167
  case 'ccswitch-balance': {
168
- // A non-subscription provider's balance, by cc-switch's query. The caller names
169
- // the provider; the configured map turns that into the credential reference the
170
- // provider's profile uses, and the host's credential service resolves it. No
171
- // entry, no key, or a key the provider does not answer for means no balance —
172
- // never a fabricated zero.
168
+ // A non-subscription provider's balance, by cc-switch's query against the provider's
169
+ // own base URL. The caller names the provider; the configured map turns that into
170
+ // the credential reference the provider's profile uses and the base URL that profile
171
+ // connects to, and the host's credential service resolves the reference. No entry,
172
+ // no key, or a key the provider does not answer for means no balance — never a
173
+ // fabricated zero.
173
174
  const provider = requiredProvider(payload, endpoint);
174
175
  const credentialRef = balance?.credentialRefs[provider];
175
176
  if (credentialRef === undefined || balance?.resolveCredential === undefined)
@@ -177,7 +178,12 @@ async function runEndpoint(registry, endpoint, payload, balance) {
177
178
  const apiKey = await balance.resolveCredential(credentialRef);
178
179
  if (apiKey === undefined)
179
180
  return undefined;
180
- return await queryCcSwitchBalance({ apiKey });
181
+ const baseUrl = balance?.baseUrls[provider] ?? 'https://api.deepseek.com';
182
+ // A provider that answers the OpenAI-compatible `/v1/usage` (the shape cc-switch's
183
+ // custom template reads, `{ remaining, unit }`) is asked that first; one that
184
+ // answers the DeepSeek `balance_infos` shape is asked that as a fallback.
185
+ return await queryUsageBalance({ apiKey, baseUrl })
186
+ ?? await queryCcSwitchBalance({ apiKey, baseUrl });
181
187
  }
182
188
  case 'reload':
183
189
  return registry.reload();
package/lib/index.d.ts CHANGED
@@ -45,16 +45,22 @@ export interface Config {
45
45
  */
46
46
  auto?: boolean;
47
47
  /**
48
- * The credential reference a non-subscription provider's balance query uses, by
49
- * provider id.
48
+ * The balance source a non-subscription provider uses, by provider id.
50
49
  *
51
50
  * A provider that is not a subscription this plugin serves has no usage windows to
52
51
  * meter, so the chip shows its balance instead — read by cc-switch's query, which needs
53
- * the provider's API key. The provider's profile names that key by reference
54
- * (`apiKeyEnv`), and the host's credential service resolves the name; this map is the
55
- * one place a provider id is tied to that reference. A provider with no entry, or
56
- * whose key does not resolve, gets no balance and the chip stays silent.
52
+ * the provider's API key and its own base URL (the balance endpoint lives beside the
53
+ * provider's OpenAI-compatible wire, not on DeepSeek's). Each entry names the
54
+ * credential reference the provider's profile uses (`apiKeyEnv`) and the base URL that
55
+ * profile connects to; the host's credential service resolves the reference. A provider
56
+ * with no entry, or whose key does not resolve, gets no balance and the chip stays
57
+ * silent.
57
58
  */
59
+ balanceSources?: Record<string, {
60
+ credentialRef: string;
61
+ baseUrl: string;
62
+ }>;
63
+ /** @deprecated Use {@link balanceSources}, which carries the base URL too. */
58
64
  balanceCredentialRefs?: Record<string, string>;
59
65
  }
60
66
  /** Configuration schema, with the config file's own defaults applied. */
package/lib/index.js CHANGED
@@ -42,6 +42,8 @@ export const Config = z.object({
42
42
  defaultModel: z.string().default(KIMI_DEFAULT_MODEL),
43
43
  extraModels: z.array(z.string()).default([]),
44
44
  auto: z.boolean().default(true),
45
+ balanceSources: z.dict(z.object({ credentialRef: z.string(), baseUrl: z.string() })).default({}),
46
+ balanceCredentialRefs: z.dict(z.string()).default({}),
45
47
  });
46
48
  /**
47
49
  * Register every configured provider route, its credential store, and — when
@@ -188,13 +190,18 @@ export function apply(ctx, config = {}) {
188
190
  handles.set(route.id, handle);
189
191
  ctx.effect(() => () => handle(), `${name}: ${route.id} provider route`);
190
192
  }
191
- // A non-subscription provider's balance is read by cc-switch's query, which needs the
192
- // provider's API key. The provider's profile names that key by reference (`apiKeyEnv`),
193
- // and the configured map ties a provider id to that reference; the host's credential
194
- // service resolves it. The map lives in server config because that is where the rest
195
- // of the plugin's wiring is, and the client learns a provider's balance by id, never
196
- // by its credential reference.
197
- const credentialRefs = config.balanceCredentialRefs ?? {};
193
+ // A non-subscription provider's balance is read by cc-switch's query against the
194
+ // provider's own base URL (the balance endpoint lives beside its OpenAI-compatible
195
+ // wire, not on DeepSeek's), which needs the provider's API key. Each configured source
196
+ // names the credential reference the provider's profile uses (`apiKeyEnv`) and the base
197
+ // URL that profile connects to; the host's credential service resolves the reference.
198
+ const balanceSources = config.balanceSources ?? {};
199
+ const credentialRefs = { ...config.balanceCredentialRefs };
200
+ const baseUrls = {};
201
+ for (const [provider, source] of Object.entries(balanceSources)) {
202
+ credentialRefs[provider] = source.credentialRef;
203
+ baseUrls[provider] = source.baseUrl;
204
+ }
198
205
  // The DeepSeek account's balance is read on the node half through the host's
199
206
  // `deepseekAccount` service; the browser reaches it through the plugin's own RPC, the
200
207
  // same channel as the rest of the chip's data. The service is read lazily, per request
@@ -202,6 +209,7 @@ export function apply(ctx, config = {}) {
202
209
  // without it still serves every other endpoint, and the balance endpoint answers nothing.
203
210
  const balanceContext = {
204
211
  credentialRefs,
212
+ baseUrls,
205
213
  resolveDeepSeekAccount: () => {
206
214
  try {
207
215
  return ctx.get('deepseekAccount');
@@ -213,7 +221,20 @@ export function apply(ctx, config = {}) {
213
221
  resolveCredential: async (ref) => {
214
222
  try {
215
223
  const credentials = ctx.get('credentials');
216
- return (await credentials?.resolve?.(ref))?.value;
224
+ const hit = await credentials?.resolve?.(ref);
225
+ // The resolve answer is `{ value, source }`; an env-var source may name the var
226
+ // rather than carry the value, so both are considered before giving up.
227
+ if (typeof hit === 'object' && hit !== null) {
228
+ const record = hit;
229
+ if (typeof record.value === 'string' && record.value.trim() !== '')
230
+ return record.value;
231
+ if (typeof record.source === 'string') {
232
+ const fromEnv = process.env[record.source];
233
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '')
234
+ return fromEnv;
235
+ }
236
+ }
237
+ return undefined;
217
238
  }
218
239
  catch {
219
240
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscription-oauth",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Use subscription accounts as DeepSeek Harness LLM providers — Kimi Code (OAuth device flow), Grok / X Premium (OIDC + PKCE), Google Antigravity (Google OAuth + Cloud Code Assist) and GLM / BigModel (ZCode's browser sign-in) — with per-model visibility, drag-to-reorder Auto fallback and plan quotas from the web Settings page",
5
5
  "license": "MIT",
6
6
  "author": "EricFL1998",