pi-cliproxyapi 0.1.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/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # pi-cliproxyapi
2
+
3
+ Pi extension for corporate management of model providers via a single [CliProxyAPI](https://github.com/nicepkg/cliproxyapi) endpoint.
4
+
5
+ One `(endpoint, apiKey)` pair — every provider and model inherits it automatically.
6
+
7
+ ## Features
8
+
9
+ - **Built-in provider routing** — whitelist which Anthropic / OpenAI / etc. models are available through the proxy
10
+ - **Custom provider groups** — create named groups (e.g. `corp-glm`, `corp-gemini`) for proxy-only models with automatic metadata from [models.dev](https://models.dev)
11
+ - **Exclusive model pool** — a model assigned to one group automatically disappears from others
12
+ - **Per-account usage overlay** — colored quota bars, toggle disabled accounts, verbose errors — no LLM call
13
+ - **Setup wizard** — `/cliproxy-setup` configures endpoint, API key, provider prefix, and usage key interactively
14
+
15
+ ## Commands
16
+
17
+ | Command | Description |
18
+ | --- | --- |
19
+ | `/cliproxy` | Interactive overlay — enable providers, toggle models, create custom groups |
20
+ | `/cliproxy-setup` | Configure endpoint, API key, provider prefix, usage key |
21
+ | `/cliproxy-refresh` | Re-fetch upstream models, re-register providers |
22
+ | `/cliproxy-list` | Read-only view of current configuration |
23
+ | `/cliproxy-usage` | Per-account quota windows with progress bars (`d` = show disabled, `v` = verbose) |
24
+ | `/cliproxy-doctor` | Connectivity, key resolution, discovery diagnostics |
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pi install pi-cliproxyapi
30
+ ```
31
+
32
+ Then run `/cliproxy-setup` to configure your proxy endpoint.
33
+
34
+ ## Config
35
+
36
+ `~/.config/pi-cliproxyapi/config.json` — created by `/cliproxy-setup`, editable by hand:
37
+
38
+ ```jsonc
39
+ {
40
+ "proxy": {
41
+ "endpoint": "https://proxy.example.com/v1",
42
+ "apiKey": "!cat ~/.config/pi-cliproxyapi/key",
43
+ "providerPrefix": "corp",
44
+ "usageKey": "!cat ~/.config/pi-cliproxyapi/usage-key"
45
+ },
46
+ "builtinProviders": {
47
+ "anthropic": { "enabled": true, "models": ["claude-opus-4-7"] },
48
+ "openai": { "enabled": true, "models": ["gpt-5.2"] }
49
+ },
50
+ "customProviders": {
51
+ "corp-glm": {
52
+ "api": "openai-completions",
53
+ "models": [{ "id": "glm-4.7", "name": "GLM 4.7" }]
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ Values support `!command` (shell exec), `$ENV_VAR`, `~/path` (auto-wrapped to `!cat`), or literal strings.
60
+
61
+ ## Discovery
62
+
63
+ The plugin tries `GET <endpoint-origin>/.well-known/pi` first (requires the companion sidecar service). If unavailable, falls back to `GET <endpoint>/models` with local heuristics.
64
+
65
+ ### Optional: companion discovery service
66
+
67
+ For richer model metadata (context windows, costs, reasoning flags from models.dev) and per-account usage, deploy **[pi-cliproxyapi-wellknown](https://github.com/abix5/pi-cliproxyapi-wellknown)** alongside your CliProxyAPI instance.
68
+
69
+ ```
70
+ ┌──────────────┐ ┌───────────────────────────┐
71
+ │ Pi + plugin │────▶│ CliProxyAPI (:8317) │
72
+ │ │ │ /v1/models, /v1/chat/... │
73
+ │ │ └───────────────────────────┘
74
+ │ │ ┌───────────────────────────┐
75
+ │ │────▶│ wellknown sidecar (:3458) │
76
+ │ │ │ /.well-known/pi │
77
+ │ │ │ /api/usage │
78
+ │ │ └───────────────────────────┘
79
+ └──────────────┘
80
+ ```
81
+
82
+ The sidecar is **optional** — the plugin works without it using `/v1/models` + local classification.
83
+
84
+ ## Layout
85
+
86
+ ```
87
+ index.ts ExtensionFactory entry point
88
+ src/
89
+ config.ts ~/.config/pi-cliproxyapi/config.json
90
+ commands.ts 6 slash commands
91
+ apply.ts pi.registerProvider calls
92
+ fetch-models.ts well-known + /v1/models fallback
93
+ fetch-usage.ts /api/usage client with TTL cache
94
+ compat.ts baseUrl derivation, model classification
95
+ conflicts.ts read-only ~/.pi/{models,auth}.json scan
96
+ ui-picker.ts overlay picker with collapsible provider groups
97
+ ui-usage.ts ANSI-colored usage renderer
98
+ ui-overlay.ts scrollable overlay shell with toggles
99
+ ui-setup.ts setup wizard
100
+ log.ts tagged logger
101
+ ```
package/index.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * pi-cliproxyapi — Pi extension that manages model providers through a single
3
+ * CliProxyAPI endpoint with one corporate key.
4
+ *
5
+ * On factory boot we:
6
+ * 1. load ~/.config/pi-cliproxyapi/config.json (defaults if missing)
7
+ * 2. fetch discovery (well-known → fall back to /v1/models)
8
+ * 3. call pi.registerProvider for each enabled built-in + custom provider
9
+ * 4. register slash commands /cliproxy /cliproxy-setup /cliproxy-refresh
10
+ * /cliproxy-list /cliproxy-usage /cliproxy-doctor
11
+ *
12
+ * All discovery + apply errors are logged but never abort extension load —
13
+ * a missing/broken proxy must not prevent Pi from starting.
14
+ */
15
+
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+
18
+ import { applyAll } from "./src/apply.ts";
19
+ import { registerCommands } from "./src/commands.ts";
20
+ import { loadConfig, resolveConfigValue } from "./src/config.ts";
21
+ import { detectConflicts } from "./src/conflicts.ts";
22
+ import { fetchDiscovery } from "./src/fetch-models.ts";
23
+ import { log } from "./src/log.ts";
24
+
25
+ export default async function cliproxyapi(pi: ExtensionAPI): Promise<void> {
26
+ registerCommands(pi);
27
+
28
+ const cfg = loadConfig();
29
+ const resolvedKey = resolveConfigValue(cfg.proxy.apiKey);
30
+ if (!resolvedKey) {
31
+ log.warn(
32
+ "apiKey is empty after resolution — skipping initial apply. Run /cliproxy-setup to configure.",
33
+ );
34
+ return;
35
+ }
36
+
37
+ // Conflict scan is read-only and cheap; do it once at startup.
38
+ const conflicts = detectConflicts(cfg);
39
+ for (const c of conflicts) log.warn(`conflict (${c.kind}): ${c.detail}`);
40
+
41
+ try {
42
+ const discovery = await fetchDiscovery(cfg, resolvedKey);
43
+ await applyAll(pi, cfg, discovery);
44
+ } catch (err) {
45
+ log.error("initial apply failed:", (err as Error).message);
46
+ // Commands stay registered; user can /cliproxy-doctor or /cliproxy-refresh.
47
+ }
48
+
49
+ if (cfg.refreshIntervalMinutes > 0) {
50
+ const ms = cfg.refreshIntervalMinutes * 60_000;
51
+ setInterval(() => {
52
+ void (async () => {
53
+ try {
54
+ const c = loadConfig();
55
+ const k = resolveConfigValue(c.proxy.apiKey);
56
+ if (!k) return;
57
+ const d = await fetchDiscovery(c, k);
58
+ await applyAll(pi, c, d);
59
+ log.debug("background refresh ok");
60
+ } catch (e) {
61
+ log.warn("background refresh failed:", (e as Error).message);
62
+ }
63
+ })();
64
+ }, ms);
65
+ log.info(`background refresh every ${cfg.refreshIntervalMinutes}m`);
66
+ }
67
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-cliproxyapi",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Pi extension for corporate management of model providers via a single CliProxyAPI endpoint",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/abix5/pi-cliproxyapi.git"
10
+ },
11
+ "files": [
12
+ "index.ts",
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "pi": {
17
+ "extensions": [
18
+ "./"
19
+ ]
20
+ },
21
+ "keywords": [
22
+ "pi-package",
23
+ "pi-extension",
24
+ "cliproxyapi"
25
+ ],
26
+ "scripts": {
27
+ "typecheck": "tsc --noEmit"
28
+ },
29
+ "peerDependencies": {
30
+ "@earendil-works/pi-ai": "*",
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "@earendil-works/pi-tui": "*"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.6.0"
36
+ }
37
+ }
package/src/apply.ts ADDED
@@ -0,0 +1,209 @@
1
+ // applyAll: take a fully-loaded config + a fresh Discovery and call
2
+ // pi.registerProvider for each enabled builtin + each custom provider.
3
+
4
+ import type { Api } from "@earendil-works/pi-ai";
5
+ import { getModels } from "@earendil-works/pi-ai";
6
+ import type {
7
+ ExtensionAPI,
8
+ ProviderConfig,
9
+ ProviderModelConfig,
10
+ } from "@earendil-works/pi-coding-agent";
11
+
12
+ import { ALLOWED_APIS, baseUrlFor, modelDefaults } from "./compat.ts";
13
+ import type { CustomProviderModelConfig, ProxyConfig } from "./config.ts";
14
+ import { resolveConfigValue } from "./config.ts";
15
+ import type { Discovery, DiscoveryCustomEntry } from "./fetch-models.ts";
16
+ import { discoveryToIdSet } from "./fetch-models.ts";
17
+ import { log } from "./log.ts";
18
+
19
+ export interface ApplyReport {
20
+ registered: Array<{ provider: string; modelCount: number; api: Api }>;
21
+ skipped: Array<{ provider: string; reason: string }>;
22
+ }
23
+
24
+ export async function applyAll(
25
+ pi: ExtensionAPI,
26
+ cfg: ProxyConfig,
27
+ discovery: Discovery,
28
+ ): Promise<ApplyReport> {
29
+ const report: ApplyReport = { registered: [], skipped: [] };
30
+ const proxyIds = discoveryToIdSet(discovery);
31
+ const resolvedKey = resolveConfigValue(cfg.proxy.apiKey);
32
+ if (!resolvedKey) {
33
+ log.warn(
34
+ "proxy apiKey is empty — pi.registerProvider calls will be skipped",
35
+ );
36
+ return report;
37
+ }
38
+
39
+ // -------- builtin providers (anthropic, openai, etc.)
40
+ for (const [name, p] of Object.entries(cfg.builtinProviders)) {
41
+ if (!p?.enabled) {
42
+ report.skipped.push({ provider: name, reason: "disabled" });
43
+ continue;
44
+ }
45
+ if (!Array.isArray(p.models) || p.models.length === 0) {
46
+ report.skipped.push({ provider: name, reason: "empty whitelist" });
47
+ continue;
48
+ }
49
+ let builtin: ReadonlyArray<{
50
+ id: string;
51
+ name: string;
52
+ api: Api;
53
+ reasoning: boolean;
54
+ input: ("text" | "image")[];
55
+ cost: any;
56
+ contextWindow: number;
57
+ maxTokens: number;
58
+ thinkingLevelMap?: any;
59
+ }>;
60
+ try {
61
+ builtin = getModels(name as any) as any;
62
+ } catch {
63
+ report.skipped.push({
64
+ provider: name,
65
+ reason: `pi-ai has no provider "${name}"`,
66
+ });
67
+ continue;
68
+ }
69
+ const selected = builtin.filter(
70
+ (m) => p.models.includes(m.id) && proxyIds.has(m.id),
71
+ );
72
+ if (selected.length === 0) {
73
+ report.skipped.push({
74
+ provider: name,
75
+ reason: "no whitelisted models present on proxy",
76
+ });
77
+ continue;
78
+ }
79
+ const api: Api = (p.apiOverride ?? selected[0]!.api) as Api;
80
+ if (!ALLOWED_APIS.has(api)) {
81
+ report.skipped.push({
82
+ provider: name,
83
+ reason: `api "${api}" not in allowlist`,
84
+ });
85
+ continue;
86
+ }
87
+ const modelDefs: ProviderModelConfig[] = selected.map((m) => {
88
+ const ov = cfg.overrides[m.id] ?? {};
89
+ const base: ProviderModelConfig = {
90
+ id: m.id,
91
+ name: typeof ov.name === "string" ? ov.name : m.name,
92
+ api,
93
+ reasoning:
94
+ typeof ov.reasoning === "boolean" ? ov.reasoning : m.reasoning,
95
+ input: m.input,
96
+ cost: ov.cost ?? m.cost,
97
+ contextWindow:
98
+ typeof ov.contextWindow === "number"
99
+ ? ov.contextWindow
100
+ : m.contextWindow,
101
+ maxTokens:
102
+ typeof ov.maxTokens === "number" ? ov.maxTokens : m.maxTokens,
103
+ };
104
+ if (m.thinkingLevelMap) base.thinkingLevelMap = m.thinkingLevelMap;
105
+ return base;
106
+ });
107
+ const providerConfig: ProviderConfig = {
108
+ name,
109
+ baseUrl: baseUrlFor(api, cfg.proxy.endpoint),
110
+ apiKey: resolvedKey,
111
+ authHeader: true,
112
+ api,
113
+ models: modelDefs,
114
+ };
115
+ pi.registerProvider(name, providerConfig);
116
+ report.registered.push({
117
+ provider: name,
118
+ modelCount: modelDefs.length,
119
+ api,
120
+ });
121
+ }
122
+
123
+ // -------- custom providers
124
+ const proxyCustomById = new Map<string, DiscoveryCustomEntry>(
125
+ discovery.customPool.map((m) => [m.id, m]),
126
+ );
127
+ for (const [name, c] of Object.entries(cfg.customProviders)) {
128
+ if (!ALLOWED_APIS.has(c.api)) {
129
+ report.skipped.push({
130
+ provider: name,
131
+ reason: `api "${c.api}" not in allowlist`,
132
+ });
133
+ continue;
134
+ }
135
+ const present: CustomProviderModelConfig[] = c.models.filter((m) =>
136
+ proxyIds.has(m.id),
137
+ );
138
+ if (present.length === 0) {
139
+ report.skipped.push({
140
+ provider: name,
141
+ reason: "no configured models present on proxy",
142
+ });
143
+ continue;
144
+ }
145
+ const modelDefs: ProviderModelConfig[] = present.map((m) => {
146
+ const fromPool = proxyCustomById.get(m.id);
147
+ const base = modelDefaults(m.id);
148
+ const ov = cfg.overrides[m.id] ?? {};
149
+ return {
150
+ id: m.id,
151
+ name: m.name ?? fromPool?.name ?? base.name ?? m.id,
152
+ api: c.api,
153
+ reasoning:
154
+ pickBool(
155
+ m.reasoning,
156
+ fromPool?.reasoning,
157
+ base.reasoning,
158
+ ov.reasoning,
159
+ ) ?? false,
160
+ input: ["text"],
161
+ cost: m.cost ??
162
+ fromPool?.cost ??
163
+ base.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
164
+ contextWindow:
165
+ pickNum(
166
+ m.contextWindow,
167
+ fromPool?.contextWindow,
168
+ base.contextWindow,
169
+ ov.contextWindow,
170
+ ) ?? 128000,
171
+ maxTokens:
172
+ pickNum(
173
+ m.maxTokens,
174
+ fromPool?.maxTokens,
175
+ base.maxTokens,
176
+ ov.maxTokens,
177
+ ) ?? 16000,
178
+ };
179
+ });
180
+ const providerConfig: ProviderConfig = {
181
+ name,
182
+ baseUrl: baseUrlFor(c.api, cfg.proxy.endpoint),
183
+ apiKey: resolvedKey,
184
+ authHeader: true,
185
+ api: c.api,
186
+ models: modelDefs,
187
+ };
188
+ pi.registerProvider(name, providerConfig);
189
+ report.registered.push({
190
+ provider: name,
191
+ modelCount: modelDefs.length,
192
+ api: c.api,
193
+ });
194
+ }
195
+
196
+ log.info(
197
+ `applyAll: registered ${report.registered.length} providers, skipped ${report.skipped.length}`,
198
+ );
199
+ return report;
200
+ }
201
+
202
+ function pickNum(...vals: Array<number | undefined>): number | undefined {
203
+ for (const v of vals) if (typeof v === "number") return v;
204
+ return undefined;
205
+ }
206
+ function pickBool(...vals: Array<boolean | undefined>): boolean | undefined {
207
+ for (const v of vals) if (typeof v === "boolean") return v;
208
+ return undefined;
209
+ }
@@ -0,0 +1,247 @@
1
+ // Slash commands.
2
+ // /cliproxy — open the picker overlay
3
+ // /cliproxy-setup — first-run / re-run setup wizard for endpoint+keys
4
+ // /cliproxy-refresh — refetch discovery + re-apply
5
+ // /cliproxy-list — show all upstream models in an overlay
6
+ // /cliproxy-usage — fetch /api/usage and render in overlay
7
+ // /cliproxy-doctor — connectivity + key-resolution diagnostics
8
+
9
+ import type {
10
+ ExtensionAPI,
11
+ ExtensionCommandContext,
12
+ } from "@earendil-works/pi-coding-agent";
13
+
14
+ import { applyAll } from "./apply.ts";
15
+ import { loadConfig, resolveConfigValue, saveConfig } from "./config.ts";
16
+ import { detectConflicts } from "./conflicts.ts";
17
+ import { fetchDiscovery, PLUGIN_USER_AGENT } from "./fetch-models.ts";
18
+ import { clearUsageCache, fetchUsage } from "./fetch-usage.ts";
19
+ import { log } from "./log.ts";
20
+ import { showOverlay } from "./ui-overlay.ts";
21
+ import { runPicker } from "./ui-picker.ts";
22
+ import { runSetup } from "./ui-setup.ts";
23
+ import { renderUsage } from "./ui-usage.ts";
24
+
25
+ export function registerCommands(pi: ExtensionAPI): void {
26
+ pi.registerCommand("cliproxy", {
27
+ description:
28
+ "Pick which models to expose via the CliProxyAPI corporate proxy",
29
+ handler: handleCliproxy.bind(null, pi),
30
+ });
31
+
32
+ pi.registerCommand("cliproxy-setup", {
33
+ description:
34
+ "Set endpoint, API key, and (optional) usage key for the proxy",
35
+ handler: handleSetup.bind(null, pi),
36
+ });
37
+
38
+ pi.registerCommand("cliproxy-refresh", {
39
+ description:
40
+ "Re-fetch upstream model list and re-apply provider registrations",
41
+ handler: handleRefresh.bind(null, pi),
42
+ });
43
+
44
+ pi.registerCommand("cliproxy-list", {
45
+ description: "Show every upstream model in a scrollable overlay",
46
+ handler: handleList,
47
+ });
48
+
49
+ pi.registerCommand("cliproxy-usage", {
50
+ description: "Show per-account quota windows from the upstream",
51
+ handler: handleUsage,
52
+ });
53
+
54
+ pi.registerCommand("cliproxy-doctor", {
55
+ description: "Check connectivity, key resolution, and discovery shape",
56
+ handler: handleDoctor,
57
+ });
58
+ }
59
+
60
+ // --------------------------------------------------------------------------- /cliproxy
61
+
62
+ async function handleCliproxy(
63
+ pi: ExtensionAPI,
64
+ _args: string,
65
+ ctx: ExtensionCommandContext,
66
+ ): Promise<void> {
67
+ const cfg = loadConfig();
68
+ if (!cfg.proxy.endpoint || !resolveConfigValue(cfg.proxy.apiKey)) {
69
+ ctx.ui.notify(
70
+ "endpoint or API key not set \u2014 launching /cliproxy-setup first",
71
+ "info",
72
+ );
73
+ const ok = await runSetup(ctx, true);
74
+ if (!ok) return;
75
+ }
76
+ const current = loadConfig();
77
+ const resolvedKey = resolveConfigValue(current.proxy.apiKey);
78
+ let discovery;
79
+ try {
80
+ discovery = await fetchDiscovery(current, resolvedKey);
81
+ } catch (err) {
82
+ ctx.ui.notify(`discovery failed: ${(err as Error).message}`, "error");
83
+ return;
84
+ }
85
+ const updated = await runPicker(ctx, current, discovery);
86
+ if (!updated) {
87
+ ctx.ui.notify("changes discarded", "info");
88
+ return;
89
+ }
90
+ saveConfig(updated);
91
+ const rep = await applyAll(pi, updated, discovery);
92
+ ctx.ui.notify(
93
+ `saved \u00b7 ${rep.registered.length} providers registered, ${rep.skipped.length} skipped`,
94
+ "info",
95
+ );
96
+ }
97
+
98
+ // --------------------------------------------------------------------------- /cliproxy-setup
99
+
100
+ async function handleSetup(
101
+ pi: ExtensionAPI,
102
+ _args: string,
103
+ ctx: ExtensionCommandContext,
104
+ ): Promise<void> {
105
+ const ok = await runSetup(ctx, true);
106
+ if (!ok) return;
107
+ // After saving, eagerly reapply so the new endpoint/key actually takes effect.
108
+ const cfg = loadConfig();
109
+ try {
110
+ const discovery = await fetchDiscovery(
111
+ cfg,
112
+ resolveConfigValue(cfg.proxy.apiKey),
113
+ );
114
+ const rep = await applyAll(pi, cfg, discovery);
115
+ clearUsageCache();
116
+ ctx.ui.notify(
117
+ `setup ok \u00b7 ${rep.registered.length} providers registered (source=${discovery.source})`,
118
+ "info",
119
+ );
120
+ } catch (err) {
121
+ ctx.ui.notify(
122
+ `setup saved, but apply failed: ${(err as Error).message}`,
123
+ "warning",
124
+ );
125
+ }
126
+ }
127
+
128
+ // --------------------------------------------------------------------------- /cliproxy-refresh
129
+
130
+ async function handleRefresh(
131
+ pi: ExtensionAPI,
132
+ _args: string,
133
+ ctx: ExtensionCommandContext,
134
+ ): Promise<void> {
135
+ const cfg = loadConfig();
136
+ const resolvedKey = resolveConfigValue(cfg.proxy.apiKey);
137
+ try {
138
+ const discovery = await fetchDiscovery(cfg, resolvedKey);
139
+ const rep = await applyAll(pi, cfg, discovery);
140
+ clearUsageCache();
141
+ ctx.ui.notify(
142
+ `cliproxy: ${rep.registered.length} providers registered, ${rep.skipped.length} skipped (source=${discovery.source})`,
143
+ "info",
144
+ );
145
+ } catch (err) {
146
+ ctx.ui.notify(`refresh failed: ${(err as Error).message}`, "error");
147
+ }
148
+ }
149
+
150
+ // --------------------------------------------------------------------------- /cliproxy-list
151
+
152
+ async function handleList(
153
+ _args: string,
154
+ ctx: ExtensionCommandContext,
155
+ ): Promise<void> {
156
+ const cfg = loadConfig();
157
+ const resolvedKey = resolveConfigValue(cfg.proxy.apiKey);
158
+ let discovery;
159
+ try {
160
+ discovery = await fetchDiscovery(cfg, resolvedKey);
161
+ } catch (err) {
162
+ ctx.ui.notify(`list failed: ${(err as Error).message}`, "error");
163
+ return;
164
+ }
165
+ await runPicker(ctx, cfg, discovery, {
166
+ readOnly: true,
167
+ title: " /cliproxy-list \u00b7 read-only ",
168
+ });
169
+ }
170
+
171
+ // --------------------------------------------------------------------------- /cliproxy-usage
172
+
173
+ async function handleUsage(
174
+ args: string,
175
+ ctx: ExtensionCommandContext,
176
+ ): Promise<void> {
177
+ const force = /(^|\s)--refresh(\s|$)/.test(args);
178
+ const cfg = loadConfig();
179
+ const usageKey = resolveConfigValue(cfg.proxy.usageKey);
180
+ let doc;
181
+ try {
182
+ doc = await fetchUsage(cfg, usageKey, { force });
183
+ } catch (err) {
184
+ ctx.ui.notify(`usage failed: ${(err as Error).message}`, "error");
185
+ return;
186
+ }
187
+ await showOverlay(ctx, "cliproxy-usage", {
188
+ render: (state) =>
189
+ renderUsage(doc, {
190
+ showDisabled: state["d"] === true,
191
+ verbose: state["v"] === true,
192
+ }).join("\n"),
193
+ toggles: [
194
+ { key: "d", hint: "d disabled" },
195
+ { key: "v", hint: "v verbose" },
196
+ ],
197
+ });
198
+ }
199
+
200
+ // --------------------------------------------------------------------------- /cliproxy-doctor
201
+
202
+ async function handleDoctor(
203
+ _args: string,
204
+ ctx: ExtensionCommandContext,
205
+ ): Promise<void> {
206
+ const cfg = loadConfig();
207
+ const lines: string[] = [];
208
+ lines.push(`endpoint: ${cfg.proxy.endpoint}`);
209
+ lines.push(
210
+ `apiKey resolves: ${resolveConfigValue(cfg.proxy.apiKey) ? "yes" : "NO (empty after resolution)"}`,
211
+ );
212
+ lines.push(
213
+ `usageKey resolves: ${cfg.proxy.usageKey ? (resolveConfigValue(cfg.proxy.usageKey) ? "yes" : "NO") : "not configured"}`,
214
+ );
215
+ lines.push(`user-agent: ${PLUGIN_USER_AGENT}`);
216
+
217
+ try {
218
+ const discovery = await fetchDiscovery(
219
+ cfg,
220
+ resolveConfigValue(cfg.proxy.apiKey),
221
+ );
222
+ lines.push("");
223
+ lines.push(`discovery source: ${discovery.source}`);
224
+ lines.push(`upstream version: ${discovery.upstreamVersion ?? "(unknown)"}`);
225
+ lines.push(`upstream total ids: ${discovery.upstreamTotal}`);
226
+ lines.push(
227
+ `built-in providers seen: ${discovery.builtinProviders.map((p) => `${p.name}=${p.models.length}`).join(", ") || "(none)"}`,
228
+ );
229
+ lines.push(`custom pool size: ${discovery.customPool.length}`);
230
+ } catch (err) {
231
+ lines.push("");
232
+ lines.push(`discovery FAILED: ${(err as Error).message}`);
233
+ }
234
+
235
+ const conflicts = detectConflicts(cfg);
236
+ if (conflicts.length > 0) {
237
+ lines.push("");
238
+ lines.push("conflicts:");
239
+ for (const c of conflicts) lines.push(` [${c.kind}] ${c.detail}`);
240
+ } else {
241
+ lines.push("");
242
+ lines.push("conflicts: none");
243
+ }
244
+
245
+ log.info("doctor:", lines.join(" | "));
246
+ await showOverlay(ctx, "cliproxy-doctor", lines.join("\n"));
247
+ }