dsh-cliproxyapi-pro 1.6.0 → 1.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.
package/lib/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // Zero-external-dependency host plugin for DSH CLIProxyAPI auto-discovery, model selection & sync.
2
- // Uses only Node.js built-ins and Cordis context services (`ctx.settings`, `ctx.credentials`, `ctx.commands`, `ctx.tools`, `ctx.webServer`).
2
+ // Uses only Node.js built-ins and Cordis context services (`ctx.settings`, `ctx.credentials`, `ctx.commands`, `ctx.webServer`).
3
+ // It deliberately registers no agent tool: accounts are managed in the Settings
4
+ // page, HTTP routes and slash commands, so nothing adds to model context.
3
5
 
4
6
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
7
  import { homedir } from "node:os";
@@ -8,6 +10,7 @@ import {
8
10
  createPluginUpdater, loadUpdateSettings, saveUpdateSettings, storeUpdatePackage,
9
11
  UPDATE_INTERVAL_MS, UpdateError,
10
12
  } from "./updater.js";
13
+ import { createQuotaController } from "./quota.js";
11
14
 
12
15
  // #region Constants & Config Resolution
13
16
 
@@ -372,7 +375,7 @@ export function newAccountDefaults(accounts) {
372
375
  }
373
376
 
374
377
  /**
375
- * Apply an account patch from the UI or the tool. `selectedModels` and
378
+ * Apply an account patch from the UI. `selectedModels` and
376
379
  * `modelOverrides` replace the stored value when present; the id never changes.
377
380
  */
378
381
  export function patchAccount(account, patch) {
@@ -905,7 +908,7 @@ export function buildPiAiRoutes(config, discovery, hasApiKey) {
905
908
  });
906
909
  if (filtered.length === 0) {
907
910
  throw new Error(
908
- `dsh-cliproxyapi: none of the selected models matched the ${discovery.models.length} model(s) offered by ${discovery.rootURL}. Select models in Settings → Built-in plugins → CLIProxyAPI or with the cliproxyapi tool.`
911
+ `dsh-cliproxyapi: none of the selected models matched the ${discovery.models.length} model(s) offered by ${discovery.rootURL}. Select models in Settings → CLIProxyAPI → Models.`
909
912
  );
910
913
  }
911
914
  const auth = hasApiKey
@@ -1589,6 +1592,34 @@ export function createCpaController(deps) {
1589
1592
  return statusPayload({ removedId: account.id });
1590
1593
  },
1591
1594
 
1595
+ /**
1596
+ * Probe a CPA address with unsaved form values, without saving or writing
1597
+ * routes. A blank `apiKey` uses the account's stored key; a blank
1598
+ * `baseURL` follows the usual fallbacks. Never returns the key.
1599
+ */
1600
+ async testConnection(ref, input = {}) {
1601
+ const state = deps.readState();
1602
+ const account = ref === undefined || ref === null || String(ref).trim() === "" ? undefined : findAccount(state, ref);
1603
+ const config = account === undefined ? undefined : accountConfig(state, account.id);
1604
+ const draftKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
1605
+ const apiKey = draftKey !== "" ? draftKey : config === undefined ? undefined : await deps.resolveApiKey(effectiveKeyEnv(config));
1606
+ const baseURL = typeof input.baseURL === "string" ? input.baseURL.trim() : "";
1607
+ try {
1608
+ const result = await discoverCpa({
1609
+ baseURL,
1610
+ autoDiscover: baseURL === "",
1611
+ fallbackBaseURL: config === undefined ? undefined : deps.readExistingPiAiRoute?.(config.provider)?.baseURL,
1612
+ environmentBaseURL: deps.environmentBaseURL?.(),
1613
+ apiKey,
1614
+ timeoutMs: state.probeTimeoutMs,
1615
+ ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
1616
+ });
1617
+ return { ok: true, test: { ok: true, endpoint: result.rootURL, modelCount: result.models.length } };
1618
+ } catch (error) {
1619
+ return { ok: true, test: { ok: false, error: error instanceof Error ? error.message : String(error) } };
1620
+ }
1621
+ },
1622
+
1592
1623
  /** Accounts whose credential reference is `ref`. */
1593
1624
  accountsUsingKey(ref) {
1594
1625
  const state = deps.readState();
@@ -1600,6 +1631,38 @@ export function createCpaController(deps) {
1600
1631
 
1601
1632
  refreshAccount,
1602
1633
 
1634
+ /**
1635
+ * Account facts the quota engine needs: the CPA address it talks to and
1636
+ * the DSH route keys whose models belong to this account. A conflicting
1637
+ * account owns no routes, so none of its models map to quota.
1638
+ */
1639
+ quotaAccounts() {
1640
+ const state = deps.readState();
1641
+ const conflicts = accountConflicts(state.accounts);
1642
+ return state.accounts.map((account) => {
1643
+ const config = accountConfig(state, account.id);
1644
+ const runtime = runtimes.get(account.id);
1645
+ const st = runtime?.currentStatus ?? {};
1646
+ const existingRoute = deps.readExistingPiAiRoute?.(config.provider);
1647
+ const owners = new Map((runtime?.allDiscoveredModels ?? []).map((m) => [m.id, m.ownedBy]));
1648
+ return {
1649
+ id: account.id,
1650
+ displayName: config.displayName,
1651
+ provider: config.provider,
1652
+ enabled: config.enabled === true,
1653
+ baseURL: config.baseURL || "",
1654
+ endpoint: st.endpoint || existingRoute?.baseURL || "",
1655
+ routes: conflicts.has(account.id) ? [] : managedRouteKeys(config.provider),
1656
+ // Models this account currently puts in the DSH picker.
1657
+ pickerModels: (runtime?.syncedModels ?? []).map((m) => ({
1658
+ id: m.id,
1659
+ route: m.route,
1660
+ ...(typeof owners.get(m.id) === "string" ? { ownedBy: owners.get(m.id) } : {}),
1661
+ })),
1662
+ };
1663
+ });
1664
+ },
1665
+
1603
1666
  dispose() {
1604
1667
  for (const runtime of runtimes.values()) runtime.dispose();
1605
1668
  runtimes.clear();
@@ -1617,10 +1680,14 @@ export const ROUTES = {
1617
1680
  save: "/api/dsh-cliproxyapi/save",
1618
1681
  addAccount: "/api/dsh-cliproxyapi/accounts/add",
1619
1682
  removeAccount: "/api/dsh-cliproxyapi/accounts/remove",
1683
+ testAccount: "/api/dsh-cliproxyapi/accounts/test",
1620
1684
  updateStatus: "/api/dsh-cliproxyapi/updates/status",
1621
1685
  updateCheck: "/api/dsh-cliproxyapi/updates/check",
1622
1686
  updateSave: "/api/dsh-cliproxyapi/updates/save",
1623
1687
  updateDownload: "/api/dsh-cliproxyapi/updates/download",
1688
+ quotaStatus: "/api/dsh-cliproxyapi/quota/status",
1689
+ quotaRefresh: "/api/dsh-cliproxyapi/quota/refresh",
1690
+ quotaSave: "/api/dsh-cliproxyapi/quota/save",
1624
1691
  };
1625
1692
 
1626
1693
  function sendJson(res, status, payload) {
@@ -1861,10 +1928,37 @@ function renderAccountStatus(account) {
1861
1928
  return lines.join("\n");
1862
1929
  }
1863
1930
 
1931
+ /** One line per account: management key, quota state and what the picker models map to. Never includes keys. */
1932
+ export function renderQuotaText(snapshot, onlyAccountId) {
1933
+ const accounts = (snapshot?.accounts ?? []).filter((a) => onlyAccountId === undefined || a.accountId === onlyAccountId);
1934
+ if (accounts.length === 0) return "";
1935
+ const lines = ["Quota (CPA management API):"];
1936
+ for (const a of accounts) {
1937
+ if (!a.keyConfigured) {
1938
+ lines.push(`- ${a.displayName} [${a.accountId}]: no management key (${a.keyRef})`);
1939
+ continue;
1940
+ }
1941
+ const creds = Array.isArray(a.credentials) ? a.credentials : [];
1942
+ const state = a.state + (a.errorCode ? ` (${a.errorCode}${a.errorStatus ? ` ${a.errorStatus}` : ""})` : "") + (a.refreshing ? ", refreshing" : "");
1943
+ lines.push(`- ${a.displayName} [${a.accountId}]: ${state}; ${creds.length} credential(s)${a.fetchedAt ? `; fetched ${a.fetchedAt}` : ""}; routes ${(a.routes ?? []).join(", ") || "none"}`);
1944
+ for (const m of a.pickerModels ?? []) {
1945
+ const ids = a.modelIndex?.[m.id] ?? [];
1946
+ const names = ids.map((id) => {
1947
+ const c = creds.find((x) => x.id === id);
1948
+ if (!c) return id;
1949
+ const pct = (c.windows ?? []).map((w) => w.remainingPercent).filter((v) => typeof v === "number");
1950
+ return `${c.provider}:${c.label || c.id}${pct.length ? ` ${Math.round(Math.min(...pct))}%` : ""}${c.status === "active" ? "" : ` (${c.status})`}`;
1951
+ });
1952
+ lines.push(` ${m.route}/${m.id} -> ${names.length ? names.join(", ") : "no credential"} [${a.modelMatch?.[m.id] ?? "none"}]`);
1953
+ }
1954
+ }
1955
+ return lines.join("\n");
1956
+ }
1957
+
1864
1958
  export function renderStatusText(payload, onlyAccountId) {
1865
1959
  const accounts = payload.accounts ?? [];
1866
1960
  if (accounts.length === 0) {
1867
- return "No CLIProxyAPI account is configured. Add one with the cliproxyapi tool (action 'add_account') or in Settings → Built-in plugins → CLIProxyAPI.";
1961
+ return "No CLIProxyAPI account is configured. Add one in Settings → CLIProxyAPI.";
1868
1962
  }
1869
1963
  const shown =
1870
1964
  onlyAccountId === undefined ? accounts : accounts.filter((a) => a.id === onlyAccountId);
@@ -1873,22 +1967,7 @@ export function renderStatusText(payload, onlyAccountId) {
1873
1967
  return [header, ...shown.map(renderAccountStatus)].join("\n\n");
1874
1968
  }
1875
1969
 
1876
- function summarizeAccount(account) {
1877
- const st = account.status;
1878
- return {
1879
- id: account.id,
1880
- displayName: account.config.displayName,
1881
- provider: account.config.provider,
1882
- enabled: account.config.enabled,
1883
- state: st.state,
1884
- ...(st.endpoint ? { endpoint: st.endpoint } : {}),
1885
- modelCount: st.modelCount ?? 0,
1886
- totalDiscovered: st.totalDiscovered ?? 0,
1887
- ...(st.error ? { error: st.error } : {}),
1888
- };
1889
- }
1890
-
1891
- /** Account fields the tool and the save route accept, besides key handling. */
1970
+ /** Account fields the save and add routes accept, besides key handling. */
1892
1971
  function accountPatchFromInput(input) {
1893
1972
  const patch = {};
1894
1973
  for (const field of ["baseURL", "provider", "displayName", "protocol", "protocolMode", "includePrefix", "excludePrefix", "apiKeyEnv"]) {
@@ -2003,6 +2082,7 @@ function applyImpl(ctx, config) {
2003
2082
  writeSettings: (preferences) => saveUpdateSettings(updateSettingsPath, preferences),
2004
2083
  storePackage: (version, bytes) => storeUpdatePackage(join(updateDirectory, "updates"), version, bytes),
2005
2084
  onSettingsChanged: scheduleUpdates,
2085
+ logger: ctx.logger,
2006
2086
  });
2007
2087
  ctx.effect(() => {
2008
2088
  scheduleUpdates(updater.status());
@@ -2079,6 +2159,26 @@ function applyImpl(ctx, config) {
2079
2159
  onGlobalsChanged: () => reschedule(),
2080
2160
  });
2081
2161
 
2162
+ // Quota ("额度" tab + composer badge): management keys live in the
2163
+ // credentials service under their own refs, never in account state.
2164
+ const quota = createQuotaController({
2165
+ listAccounts: () => controller.quotaAccounts(),
2166
+ resolveKey: (ref) => resolveApiKey(ref),
2167
+ describeKey: (ref) => describeApiKey(ref),
2168
+ setKey: async (ref, value) => {
2169
+ const credentials = ctx.get("credentials");
2170
+ if (credentials === undefined) {
2171
+ throw new Error("Credentials service is not available on this host");
2172
+ }
2173
+ await credentials.set(ref, value);
2174
+ },
2175
+ unsetKey: async (ref) => {
2176
+ await ctx.get("credentials")?.unset(ref);
2177
+ },
2178
+ logger: ctx.logger,
2179
+ });
2180
+ ctx.effect(() => () => quota.dispose(), "cpa: quota");
2181
+
2082
2182
  reschedule();
2083
2183
  startupTimeout = outsideHmrTransaction(ctx, () =>
2084
2184
  setTimeout(() => {
@@ -2110,7 +2210,8 @@ function applyImpl(ctx, config) {
2110
2210
  description: "Show CLIProxyAPI discovery and model synchronization status of every account.",
2111
2211
  handler: async () => {
2112
2212
  const payload = await controller.statusPayload();
2113
- return { kind: "success", text: renderStatusText(payload) };
2213
+ const quotaText = renderQuotaText(await quota.snapshot());
2214
+ return { kind: "success", text: [renderStatusText(payload), quotaText].filter(Boolean).join("\n\n") };
2114
2215
  },
2115
2216
  });
2116
2217
  const offRefresh = cmdCtx.commands.register({
@@ -2129,158 +2230,6 @@ function applyImpl(ctx, config) {
2129
2230
  }, "cpa: commands");
2130
2231
  });
2131
2232
 
2132
- // Register Agent Tool (cliproxyapi) directly via ctx.tools.register
2133
- ctx.inject(["tools"], (toolCtx) => {
2134
- toolCtx.effect(() => {
2135
- return toolCtx.tools.register({
2136
- name: "cliproxyapi",
2137
- description:
2138
- "Inspect, refresh, or configure CLIProxyAPI accounts (each one a CPA server + API key synchronized into its own DSH provider routes), add or remove accounts, enable or disable them, and filter which models each one shows in the DSH model selector.",
2139
- parameters: {
2140
- type: "object",
2141
- additionalProperties: false,
2142
- required: ["action"],
2143
- properties: {
2144
- action: {
2145
- type: "string",
2146
- enum: ["status", "refresh", "configure", "add_account", "remove_account"],
2147
- description:
2148
- "'status' reads every account (or only `account`), 'refresh' probes and syncs now, 'configure' updates one account, 'add_account' creates an account from the other fields, 'remove_account' deletes `account` and its model routes.",
2149
- },
2150
- account: {
2151
- type: "string",
2152
- description:
2153
- "Target account id (or its provider route ID / display name) for status, refresh, configure and remove_account. Defaults to the first account; refresh without it refreshes all.",
2154
- },
2155
- enabled: {
2156
- type: "boolean",
2157
- description:
2158
- "Enable or disable the account. A disabled account keeps its settings but its models leave the picker. add_account defaults to enabled when baseURL is given.",
2159
- },
2160
- baseURL: {
2161
- type: "string",
2162
- description:
2163
- "CLIProxyAPI base URL (e.g. http://127.0.0.1:8317). Empty means auto-discover locally.",
2164
- },
2165
- provider: {
2166
- type: "string",
2167
- description:
2168
- "Provider route ID in llm-pi-ai; must be unique per account (default: cli-proxy, then cli-proxy-2, ...).",
2169
- },
2170
- displayName: {
2171
- type: "string",
2172
- description: "Account name shown in the DSH model picker.",
2173
- },
2174
- protocolMode: {
2175
- type: "string",
2176
- enum: ["auto", "fixed"],
2177
- description:
2178
- "'auto' (default) sends each model over its own family's protocol: Claude via Anthropic Messages, GPT/Codex/Grok via OpenAI Responses, others (incl. Gemini) via OpenAI Chat. 'fixed' sends every model over `protocol`.",
2179
- },
2180
- protocol: {
2181
- type: "string",
2182
- enum: ["openai-completions", "openai-responses", "anthropic-messages"],
2183
- description: "Wire protocol used for every model when protocolMode is 'fixed'.",
2184
- },
2185
- selectedModels: {
2186
- type: "array",
2187
- items: { type: "string" },
2188
- description:
2189
- "Exact model IDs to show in the DSH model picker. Pass an empty array [] to show all discovered models.",
2190
- },
2191
- includePrefix: {
2192
- type: "string",
2193
- description: "Comma-separated model ID substrings/prefixes to include.",
2194
- },
2195
- excludePrefix: {
2196
- type: "string",
2197
- description: "Comma-separated model ID substrings/prefixes to exclude.",
2198
- },
2199
- apiKey: {
2200
- type: "string",
2201
- description:
2202
- "CLIProxyAPI Bearer key, stored in the DSH credentials service under the account's apiKeyEnv.",
2203
- },
2204
- apiKeyEnv: {
2205
- type: "string",
2206
- description:
2207
- "Credential reference name holding the account's key (default: CLI_PROXY_API_KEY, then CLI_PROXY_API_KEY_2, ...).",
2208
- },
2209
- modelOverrides: {
2210
- type: "object",
2211
- description:
2212
- "Per-model overrides keyed by model ID, replacing the account's whole map: { [modelId]: { contextWindow?: number, maxTokens?: number, image?: boolean, reasoningLevels?: string[], protocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages' } }.",
2213
- },
2214
- },
2215
- },
2216
- output: {
2217
- schema: {
2218
- type: "object",
2219
- additionalProperties: false,
2220
- required: ["accounts", "summary"],
2221
- properties: {
2222
- account: { type: "string" },
2223
- accounts: {
2224
- type: "array",
2225
- items: {
2226
- type: "object",
2227
- additionalProperties: false,
2228
- required: ["id", "displayName", "provider", "enabled", "state", "modelCount", "totalDiscovered"],
2229
- properties: {
2230
- id: { type: "string" },
2231
- displayName: { type: "string" },
2232
- provider: { type: "string" },
2233
- enabled: { type: "boolean" },
2234
- state: { type: "string" },
2235
- endpoint: { type: "string" },
2236
- modelCount: { type: "integer" },
2237
- totalDiscovered: { type: "integer" },
2238
- error: { type: "string" },
2239
- },
2240
- },
2241
- },
2242
- summary: { type: "string" },
2243
- },
2244
- },
2245
- render: (_args, value) => [{ type: "text", text: value.summary }],
2246
- },
2247
- async execute(args) {
2248
- const input = args ?? {};
2249
- const ref = typeof input.account === "string" && input.account.trim() !== "" ? input.account.trim() : undefined;
2250
- let target;
2251
- if (input.action === "refresh") {
2252
- target = (await controller.refresh(ref)).account;
2253
- } else if (input.action === "configure") {
2254
- target = (await controller.updateAccount(ref, accountPatchFromInput(input))).account;
2255
- } else if (input.action === "add_account") {
2256
- const patch = accountPatchFromInput(input);
2257
- if (patch.enabled === undefined) {
2258
- patch.enabled = typeof patch.baseURL === "string" && patch.baseURL.trim() !== "";
2259
- }
2260
- target = (await controller.addAccount(patch)).createdId;
2261
- } else if (input.action === "remove_account") {
2262
- if (ref === undefined) throw new Error("remove_account needs `account`");
2263
- await controller.removeAccount(ref);
2264
- } else if (ref !== undefined) {
2265
- target = controller.findAccount(ref).id;
2266
- }
2267
- const payload = await controller.statusPayload();
2268
- return {
2269
- ...(target === undefined ? {} : { account: target }),
2270
- accounts: payload.accounts.map(summarizeAccount),
2271
- summary: renderStatusText(payload, input.action === "status" ? target : undefined),
2272
- };
2273
- },
2274
- presentCall: (args) => ({
2275
- card: "generic",
2276
- title: `CLIProxyAPI: ${args?.action ?? "status"}${args?.account ? ` (${args.account})` : ""}`,
2277
- kind: args?.action === "status" ? "read" : "execute",
2278
- rawInput: args,
2279
- }),
2280
- });
2281
- }, "cpa: tool");
2282
- });
2283
-
2284
2233
  // Register HTTP API Routes on webServer for the Web UI
2285
2234
  ctx.inject(["webServer"], (webCtx) => {
2286
2235
  webCtx.effect(() => {
@@ -2341,6 +2290,30 @@ function applyImpl(ctx, config) {
2341
2290
  ),
2342
2291
  route(ROUTES.addAccount, "POST", (body) => controller.addAccount(accountPatchFromInput(body)), 400),
2343
2292
  route(ROUTES.removeAccount, "POST", (body) => controller.removeAccount(body.accountId), 400),
2293
+ route(ROUTES.testAccount, "POST", (body) => controller.testConnection(body.accountId, { baseURL: body.baseURL, apiKey: body.apiKey }), 400),
2294
+ route(ROUTES.quotaStatus, "GET", async () => ({ ok: true, quota: await quota.snapshot() })),
2295
+ route(ROUTES.quotaRefresh, "POST", async (body) => ({
2296
+ ok: true,
2297
+ quota: await quota.refresh(typeof body.accountId === "string" && body.accountId !== "" ? body.accountId : undefined, { wait: false }),
2298
+ })),
2299
+ route(
2300
+ ROUTES.quotaSave,
2301
+ "POST",
2302
+ async (body) => {
2303
+ if (typeof body.accountId !== "string" || body.accountId === "") {
2304
+ throw new Error("dsh-cliproxyapi: saving a management key needs accountId");
2305
+ }
2306
+ const managementKey = typeof body.managementKey === "string" ? body.managementKey.trim() : "";
2307
+ return {
2308
+ ok: true,
2309
+ quota: await quota.saveKey(body.accountId, {
2310
+ ...(managementKey === "" ? {} : { managementKey }),
2311
+ ...(body.clear === true ? { clear: true } : {}),
2312
+ }, { wait: false }),
2313
+ };
2314
+ },
2315
+ 400
2316
+ ),
2344
2317
  ];
2345
2318
  return () => {
2346
2319
  for (const dispose of disposers) dispose?.();