@hystudio/dsh-quota-pill 0.2.7 → 0.2.11

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.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Adapter keys ↔ DSH provider/model ids.
3
+ *
4
+ * Two different namespaces meet here:
5
+ * · DSH names providers after the upstream or the user's provider config
6
+ * (`grok`, `commandcode-goat`, `opendesign-go`, `deepseek-account`,
7
+ * `opencode-go`), and models carry that id as their prefix;
8
+ * · adapters in this package use short historical keys (`xai`, `commandcode`,
9
+ * `opendesign`, `deepseek`, `opencode`).
10
+ *
11
+ * A missing alias is invisible in the worst way: the provider is configured and
12
+ * readable, but the catalog filter drops it before any adapter runs. Keep these
13
+ * maps in one place and test them against a real catalog shape.
14
+ */
15
+
16
+ /** Which DSH model prefixes map to which adapter. */
17
+ export const PREFIX_ALIASES = {
18
+ 'kimi-coding': 'kimi',
19
+ kimi: 'kimi',
20
+ 'opencode-go': 'opencode',
21
+ opencode: 'opencode',
22
+ commandcode: 'commandcode',
23
+ 'command-code': 'commandcode',
24
+ 'commandcode-goat': 'commandcode',
25
+ xai: 'xai',
26
+ grok: 'xai',
27
+ codex: 'codex',
28
+ antigravity: 'agy',
29
+ agy: 'agy',
30
+ deepseek: 'deepseek',
31
+ 'deepseek-account': 'deepseek',
32
+ opendesign: 'opendesign',
33
+ 'opendesign-go': 'opendesign',
34
+ };
35
+
36
+ /**
37
+ * Which DSH catalog ids count as "this adapter".
38
+ *
39
+ * DSH names providers after the upstream (`antigravity`, `deepseek-account`,
40
+ * `opencode-go`); the adapters use the short keys the pill has always used.
41
+ */
42
+ export const CATALOG_ALIASES = {
43
+ agy: ['antigravity', 'agy'],
44
+ deepseek: ['deepseek-account', 'deepseek'],
45
+ opencode: ['opencode-go', 'opencode'],
46
+ kimi: ['kimi-coding', 'kimi'],
47
+ // DSH ids follow the upstream/provider config name, not the short adapter
48
+ // key. Missing one of these hides a provider the user actually configured:
49
+ // grok (xAI), commandcode-goat (Command Code), opendesign-go (Open Design).
50
+ xai: ['xai', 'grok'],
51
+ codex: ['codex'],
52
+ commandcode: ['commandcode', 'commandcode-goat', 'command-code'],
53
+ opendesign: ['opendesign', 'opendesign-go'],
54
+ };
55
+
56
+ /**
57
+ * Providers contributed by the user's own adapters (~/.dsh/quota-pill/adapters).
58
+ * Kept separate from the built-in maps so a package update can re-register them
59
+ * from disk without ever rewriting the user's files.
60
+ */
61
+ export const USER_PREFIX_ALIASES = {};
62
+ export const USER_CATALOG_ALIASES = {};
63
+ export const USER_DISPLAY_NAMES = {};
64
+
65
+ export function clearUserProviders() {
66
+ for (const key of Object.keys(USER_PREFIX_ALIASES)) delete USER_PREFIX_ALIASES[key];
67
+ for (const key of Object.keys(USER_CATALOG_ALIASES)) delete USER_CATALOG_ALIASES[key];
68
+ for (const key of Object.keys(USER_DISPLAY_NAMES)) delete USER_DISPLAY_NAMES[key];
69
+ }
70
+
71
+ /** Register one user adapter's ids/name. Called on every adapter-dir reload. */
72
+ export function registerUserProvider({ provider, catalogIds = [], prefixes = [], displayName } = {}) {
73
+ if (!provider) return;
74
+ const key = String(provider);
75
+ const ids = new Set([key, ...(Array.isArray(catalogIds) ? catalogIds : []), ...(Array.isArray(prefixes) ? prefixes : [])]);
76
+ for (const id of ids) {
77
+ if (!id) continue;
78
+ USER_PREFIX_ALIASES[String(id).toLowerCase()] = key;
79
+ }
80
+ USER_CATALOG_ALIASES[key] = Array.from(new Set([key, ...(USER_CATALOG_ALIASES[key] || []), ...(Array.isArray(catalogIds) ? catalogIds : [])]));
81
+ if (displayName) USER_DISPLAY_NAMES[key] = String(displayName);
82
+ }
83
+
84
+ export function providerForModel(model) {
85
+ const m = String(model || '').toLowerCase();
86
+ const prefix = m.split('/')[0];
87
+ // User adapters win: their catalog id is exactly what DSH uses for the model
88
+ // prefix, and the user added the adapter specifically for it.
89
+ if (USER_PREFIX_ALIASES[prefix]) return USER_PREFIX_ALIASES[prefix];
90
+ if (PREFIX_ALIASES[prefix]) return PREFIX_ALIASES[prefix];
91
+ // Fuzzy fallbacks for models whose prefix is not the provider id.
92
+ if (m.includes('grok')) return 'xai';
93
+ if (m.includes('codex') || m.startsWith('gpt-')) return 'codex';
94
+ if (m.includes('gemini')) return 'agy';
95
+ if (m.startsWith('k3') || m.startsWith('kimi')) return 'kimi';
96
+ return '';
97
+ }
98
+
99
+ export function inCatalog(provider, ids) {
100
+ if (!ids) return true; // catalog unknown: do not hide anything
101
+ const names = [
102
+ ...(CATALOG_ALIASES[provider] || []),
103
+ ...(USER_CATALOG_ALIASES[provider] || []),
104
+ ];
105
+ if (!names.length) names.push(provider);
106
+ return names.some((n) => ids.includes(n));
107
+ }
108
+
109
+ /** Display name for user adapters (built-ins keep their server-side table). */
110
+ export function userDisplayName(provider) {
111
+ return USER_DISPLAY_NAMES[provider] || null;
112
+ }
package/src/server.js CHANGED
@@ -19,14 +19,19 @@
19
19
  * Every response includes `cached` and `at` so the client can tell a live
20
20
  * reading from a cached one.
21
21
  */
22
+ import fs from 'node:fs';
22
23
  import http from 'node:http';
23
24
  import { kimi, opencode, commandcode } from './adapters-apikey.js';
24
25
  import { xai, codex, agy } from './adapters-cliproxy.js';
25
26
  import { deepseek } from './adapters-deepseek.js';
26
27
  import { credentialSource, hasCliProxy, CLI_PROXY_DIR, CREDENTIALS_FILE } from './credentials.js';
27
- import { catalogProviderIds } from './catalog.js';
28
+ import { loadUserAdapters, adapterContext, userAdaptersInfo } from './user-adapters.js';
29
+ import { catalogProviderIds, forgetCatalog } from './catalog.js';
28
30
  import { opendesign } from './adapters-opendesign.js';
31
+ import { providerForModel, inCatalog } from './providers.js';
29
32
 
33
+ const PKG = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
34
+ const VERSION = PKG.version;
30
35
  const PORT = Number(process.env.QUOTA_PILL_PORT || 8319);
31
36
  const HOST = process.env.QUOTA_PILL_HOST || '127.0.0.1';
32
37
  const CACHE_TTL_MS = Number(process.env.QUOTA_PILL_CACHE_TTL || 60) * 1000;
@@ -47,37 +52,33 @@ const DISPLAY_NAMES = {
47
52
  opendesign: 'Open Design',
48
53
  };
49
54
 
50
- /** Which DSH model prefixes map to which adapter. */
51
- const PREFIX_ALIASES = {
52
- 'kimi-coding': 'kimi',
53
- kimi: 'kimi',
54
- 'opencode-go': 'opencode',
55
- opencode: 'opencode',
56
- commandcode: 'commandcode',
57
- 'command-code': 'commandcode',
58
- xai: 'xai',
59
- codex: 'codex',
60
- antigravity: 'agy',
61
- agy: 'agy',
62
- deepseek: 'deepseek',
63
- opendesign: 'opendesign',
64
- };
55
+ /** Provider ids contributed by ~/.dsh/quota-pill/adapters (tracked to purge stale ones). */
56
+ let userProviderNames = new Set();
57
+
58
+ /**
59
+ * Reload user adapters from disk and sync them into the request-time registry.
60
+ * Cheap when the directory has not changed; force on ?fresh=1.
61
+ */
62
+ async function refreshUserAdapters({ force = false } = {}) {
63
+ const info = await loadUserAdapters({ force });
64
+ const next = new Set(info.adapters.map((a) => a.provider));
65
+ for (const provider of userProviderNames) {
66
+ if (!next.has(provider)) {
67
+ delete ADAPTERS[provider];
68
+ delete DISPLAY_NAMES[provider];
69
+ }
70
+ }
71
+ for (const adapter of info.adapters) {
72
+ ADAPTERS[adapter.provider] = (model) => adapter.read(adapterContext(model));
73
+ DISPLAY_NAMES[adapter.provider] = adapter.displayName;
74
+ }
75
+ userProviderNames = next;
76
+ return info;
77
+ }
65
78
 
66
79
  /** provider|model → { at, payload }. Keyed by model because readings differ per model. */
67
80
  const cache = new Map();
68
81
 
69
- function providerForModel(model) {
70
- const m = String(model || '').toLowerCase();
71
- const prefix = m.split('/')[0];
72
- if (PREFIX_ALIASES[prefix]) return PREFIX_ALIASES[prefix];
73
- // Fuzzy fallbacks for models whose prefix is not the provider id.
74
- if (m.includes('grok')) return 'xai';
75
- if (m.includes('codex') || m.startsWith('gpt-')) return 'codex';
76
- if (m.includes('gemini')) return 'agy';
77
- if (m.startsWith('k3') || m.startsWith('kimi')) return 'kimi';
78
- return '';
79
- }
80
-
81
82
  async function readOne(provider, model, { fresh = false } = {}) {
82
83
  const fn = ADAPTERS[provider];
83
84
  if (!fn) return { kind: 'none', provider, error: `no adapter for '${provider}'` };
@@ -105,29 +106,6 @@ async function readOne(provider, model, { fresh = false } = {}) {
105
106
  return { ...payload, cached: false, at: now };
106
107
  }
107
108
 
108
- /**
109
- * Which DSH catalog ids count as "this adapter".
110
- *
111
- * DSH names providers after the upstream (`antigravity`, `deepseek-account`,
112
- * `opencode-go`); the adapters use the short keys the pill has always used.
113
- */
114
- const CATALOG_ALIASES = {
115
- agy: ['antigravity', 'agy'],
116
- deepseek: ['deepseek-account', 'deepseek'],
117
- opencode: ['opencode-go', 'opencode'],
118
- kimi: ['kimi-coding', 'kimi'],
119
- xai: ['xai'],
120
- codex: ['codex'],
121
- commandcode: ['commandcode'],
122
- opendesign: ['opendesign'],
123
- };
124
-
125
- function inCatalog(provider, ids) {
126
- if (!ids) return true; // catalog unknown: do not hide anything
127
- const names = CATALOG_ALIASES[provider] || [provider];
128
- return names.some((n) => ids.includes(n));
129
- }
130
-
131
109
  /**
132
110
  * Every provider worth showing: present in DSH's catalog AND actually readable.
133
111
  *
@@ -141,6 +119,7 @@ function inCatalog(provider, ids) {
141
119
  * failure is information the user needs.
142
120
  */
143
121
  async function readAll({ fresh = false } = {}) {
122
+ await refreshUserAdapters({ force: fresh });
144
123
  const ids = await catalogProviderIds();
145
124
  const wanted = Object.keys(ADAPTERS).filter((p) => inCatalog(p, ids));
146
125
 
@@ -153,20 +132,48 @@ async function readAll({ fresh = false } = {}) {
153
132
  }
154
133
 
155
134
  /** What is configured and what is missing — powers the agent-driven setup. */
156
- function describeSetup() {
135
+ /** Human-readable "what this provider still needs" for /setup and AGENTS.md. */
136
+ function requiresText(src) {
137
+ if (src.requires) return String(src.requires);
138
+ if (src.kind === 'cli-proxy') return 'CLIProxyAPI + OAuth login';
139
+ if (src.kind === 'dsh-account') return 'DSH Platform sign-in (nothing to add)';
140
+ const refs = (src.refs && src.refs.length) ? src.refs : (src.ref ? [src.ref] : []);
141
+ if (!refs.length) return 'no credential source';
142
+ const named = refs.map((r) => `refs.${r}`).join(' or ');
143
+ if (src.kind === 'control-key') {
144
+ const ws = (src.workspaceRefs || []).map((r) => `refs.${r}`).join(' or ')
145
+ || 'an explicit workspace ref (optional: resolved from /workspaces/current)';
146
+ const cookie = src.cookieRef ? ` or console cookie refs.${src.cookieRef}` : '';
147
+ return `~/.dsh/.credentials.yaml → ${named} + ${ws}${cookie}`;
148
+ }
149
+ const suffix = src.kind === 'session-cookie' ? ' (console session cookie)' : '';
150
+ return `~/.dsh/.credentials.yaml → ${named}${suffix}`;
151
+ }
152
+
153
+ async function describeSetup(info) {
154
+ const catalog = await catalogProviderIds();
157
155
  const providers = Object.keys(ADAPTERS).map((p) => {
158
156
  const src = credentialSource(p);
159
157
  return {
160
158
  provider: p,
161
159
  name: DISPLAY_NAMES[p] || p,
162
- requires: src.kind === 'cli-proxy' ? 'CLIProxyAPI + OAuth login' : `~/.dsh/.credentials.yaml → refs.${src.ref}`,
160
+ requires: requiresText(src),
161
+ inCatalog: catalog ? inCatalog(p, catalog) : null,
163
162
  credential: src,
164
163
  };
165
164
  });
165
+ const adapted = new Set(
166
+ catalog ? catalog.filter((id) => Object.keys(ADAPTERS).some((p) => inCatalog(p, [id]))) : [],
167
+ );
166
168
  return {
167
169
  ok: true,
170
+ version: VERSION,
171
+ catalog,
172
+ catalogSource: 'DSH session/modelCatalog',
173
+ unadaptedCatalog: catalog ? catalog.filter((id) => !adapted.has(id)) : [],
168
174
  credentialsFile: CREDENTIALS_FILE,
169
175
  cliProxy: { dir: CLI_PROXY_DIR, present: hasCliProxy() },
176
+ userAdapters: info ? { ...userAdaptersInfo(), errors: info.errors } : userAdaptersInfo(),
170
177
  providers,
171
178
  };
172
179
  }
@@ -187,22 +194,31 @@ const server = http.createServer(async (req, res) => {
187
194
 
188
195
  try {
189
196
  if (url.pathname === '/health') {
190
- return json(res, 200, { ok: true, port: PORT, providers: Object.keys(ADAPTERS) });
197
+ await refreshUserAdapters({ force: fresh });
198
+ return json(res, 200, { ok: true, port: PORT, version: VERSION, providers: Object.keys(ADAPTERS) });
191
199
  }
192
200
  if (url.pathname === '/setup') {
193
- return json(res, 200, describeSetup());
201
+ const userInfo = await refreshUserAdapters({ force: fresh });
202
+ if (fresh) forgetCatalog();
203
+ return json(res, 200, await describeSetup(userInfo));
194
204
  }
195
205
  if (url.pathname === '/quota/all') {
206
+ // The catalog is DSH's live provider list. A forced read is how a newly
207
+ // added provider appears without restarting the sidecar or waiting for
208
+ // the 120s TTL.
209
+ if (fresh) forgetCatalog();
196
210
  const all = await readAll({ fresh });
197
211
  return json(res, 200, all);
198
212
  }
199
213
  if (url.pathname === '/quota') {
200
214
  const model = url.searchParams.get('model') || '';
215
+ await refreshUserAdapters({ force: fresh });
201
216
  const provider = providerForModel(model);
202
217
  if (!provider) {
203
218
  return json(res, 200, {
204
219
  kind: 'none',
205
220
  provider: 'unknown',
221
+ displayName: 'Unknown provider',
206
222
  model,
207
223
  error: `no adapter matches model '${model}'`,
208
224
  });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * User-contributed provider adapters.
3
+ *
4
+ * The package only ships eight built-ins. A DSH user can add a provider the
5
+ * built-ins do not know (and an agent can do it for them) by dropping a module
6
+ * in:
7
+ *
8
+ * ~/.dsh/quota-pill/adapters/<provider>.mjs
9
+ *
10
+ * That directory is OUTSIDE the npm package, so updating/reinstalling the plugin
11
+ * never overwrites it. The sidecar discovers it on every request (mtime check),
12
+ * so adding a file and pressing Refresh is enough — no restart.
13
+ *
14
+ * Contract (all exports optional except provider/read):
15
+ *
16
+ * export const provider = 'myprovider';
17
+ * export const catalogIds = ['myprovider-go']; // DSH catalog/model ids
18
+ * export const displayName = 'My Provider';
19
+ * export const credential = { kind: 'api-key', refs: ['MYPROVIDER_API_KEY'] };
20
+ * export async function read(ctx) {
21
+ * // ctx.model, ctx.readRef, ctx.readFirstRef, ctx.getJson,
22
+ * // ctx.getJsonViaCurl, ctx.postJsonViaCurl, ctx.env, ctx.dshHome
23
+ * return { kind: 'subscription-quota', remainingPct: 42, window: '5h', ... };
24
+ * }
25
+ *
26
+ * The result must use the same kinds as the built-in adapters
27
+ * (subscription-quota / payg / usage-only / none / probe-failed). Never invent
28
+ * a number: if the provider has no quota API, say so in `error`.
29
+ */
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import { pathToFileURL } from 'node:url';
33
+ import {
34
+ DSH_DIR,
35
+ readRef,
36
+ readFirstRef,
37
+ registerCredentialSource,
38
+ clearUserCredentialSources,
39
+ } from './credentials.js';
40
+ import { getJson, getJsonViaCurl, postJsonViaCurl } from './http.js';
41
+ import { clearUserProviders, registerUserProvider } from './providers.js';
42
+
43
+ export const USER_ADAPTER_DIR = process.env.QUOTA_PILL_USER_ADAPTER_DIR
44
+ || path.join(DSH_DIR, 'quota-pill', 'adapters');
45
+
46
+ let current = { dir: USER_ADAPTER_DIR, adapters: [], errors: [], loadedAt: 0, signature: '' };
47
+
48
+ function signatureOf(dir) {
49
+ let files = [];
50
+ try {
51
+ files = fs.readdirSync(dir)
52
+ .filter((f) => /\.(mjs|js)$/i.test(f) && !f.endsWith('.test.mjs'))
53
+ .sort();
54
+ } catch {
55
+ return { files: [], sig: dir + '|missing' };
56
+ }
57
+ const parts = [];
58
+ for (const f of files) {
59
+ try {
60
+ const s = fs.statSync(path.join(dir, f));
61
+ parts.push(f + ':' + s.mtimeMs + ':' + s.size);
62
+ } catch {
63
+ parts.push(f + ':missing');
64
+ }
65
+ }
66
+ return { files, sig: dir + '|' + parts.join('|') };
67
+ }
68
+
69
+ function normalizeCredential(raw, provider) {
70
+ if (raw && typeof raw === 'object') {
71
+ return {
72
+ kind: raw.kind || 'user-adapter',
73
+ refs: Array.isArray(raw.refs) ? raw.refs : (raw.ref ? [raw.ref] : []),
74
+ ref: raw.ref || (Array.isArray(raw.refs) ? raw.refs[0] : null) || null,
75
+ requires: raw.requires || null,
76
+ ...raw,
77
+ };
78
+ }
79
+ return {
80
+ kind: 'user-adapter',
81
+ refs: [],
82
+ ref: null,
83
+ requires: 'credentials defined by the user adapter ' + provider,
84
+ };
85
+ }
86
+
87
+ async function importAdapter(full, stat) {
88
+ // The mtime/size query busts Node's ESM cache, so editing an adapter and
89
+ // refreshing really reloads it instead of serving the first version.
90
+ const href = pathToFileURL(full).href + '?v=' + encodeURIComponent(stat.mtimeMs + '-' + stat.size);
91
+ const mod = await import(href);
92
+ const spec = (mod && mod.default && typeof mod.default === 'object') ? { ...mod.default, ...mod } : mod;
93
+ const fallback = path.basename(full, path.extname(full));
94
+ const provider = String(spec.provider || fallback).trim();
95
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(provider)) {
96
+ throw new Error('invalid provider id: ' + JSON.stringify(provider));
97
+ }
98
+ if (typeof spec.read !== 'function') throw new Error('adapter must export an async read(ctx) function');
99
+ return {
100
+ provider,
101
+ read: spec.read,
102
+ displayName: spec.displayName ? String(spec.displayName) : provider,
103
+ catalogIds: Array.isArray(spec.catalogIds) ? spec.catalogIds.map(String).filter(Boolean) : [provider],
104
+ prefixes: Array.isArray(spec.prefixes) ? spec.prefixes.map(String).filter(Boolean) : [],
105
+ credential: normalizeCredential(spec.credential, provider),
106
+ file: full,
107
+ };
108
+ }
109
+
110
+ /** Load/reload user adapters. Cheap when the directory has not changed. */
111
+ export async function loadUserAdapters({ force = false, dir = USER_ADAPTER_DIR } = {}) {
112
+ try {
113
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
114
+ } catch {
115
+ // read-only home: scan whatever exists, /setup will show the error
116
+ }
117
+ const { files, sig } = signatureOf(dir);
118
+ if (!force && sig === current.signature) return current;
119
+
120
+ // Rebuild registrations from scratch so a deleted file also unregisters its
121
+ // provider instead of leaving a stale adapter/mapping behind.
122
+ clearUserProviders();
123
+ clearUserCredentialSources();
124
+
125
+ const adapters = [];
126
+ const errors = [];
127
+ for (const file of files) {
128
+ const full = path.join(dir, file);
129
+ try {
130
+ const stat = fs.statSync(full);
131
+ const adapter = await importAdapter(full, stat);
132
+ adapters.push(adapter);
133
+ registerUserProvider(adapter);
134
+ registerCredentialSource(adapter.provider, adapter.credential);
135
+ } catch (e) {
136
+ errors.push({ file, error: String((e && e.message) || e) });
137
+ }
138
+ }
139
+
140
+ current = { dir, adapters, errors, loadedAt: Date.now(), signature: sig };
141
+ return current;
142
+ }
143
+
144
+ export function userAdapterProviders() {
145
+ return current.adapters.map((a) => a.provider);
146
+ }
147
+
148
+ export function userAdapterFor(provider) {
149
+ return current.adapters.find((a) => a.provider === provider) || null;
150
+ }
151
+
152
+ export function userAdaptersInfo() {
153
+ return {
154
+ dir: current.dir,
155
+ providers: current.adapters.map((a) => ({ provider: a.provider, displayName: a.displayName, file: a.file })),
156
+ errors: current.errors,
157
+ };
158
+ }
159
+
160
+ /** Context passed to a user adapter's read(ctx). */
161
+ export function adapterContext(model) {
162
+ return {
163
+ model,
164
+ readRef,
165
+ readFirstRef,
166
+ getJson,
167
+ getJsonViaCurl,
168
+ postJsonViaCurl,
169
+ env: process.env,
170
+ dshHome: DSH_DIR,
171
+ };
172
+ }