@samrito/pi-cliproxyapi-provider 0.16.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.
@@ -0,0 +1,40 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
4
+ import { DEFAULT_CONFIG, loadConfig } from "../src/config.ts";
5
+ import { ProviderCatalog } from "../src/catalog.ts";
6
+ import { ProviderRuntime } from "../src/runtime.ts";
7
+ import { buildProviderRegistration } from "../src/registration.ts";
8
+ import { buildUnavailableProviderModels } from "../src/provider.ts";
9
+ import { registerCliproxyapiCommand } from "../src/commands.ts";
10
+ import { getDiscoveryApiKey } from "../src/auth.ts";
11
+ import { loadProviderSettings } from "../src/settings.ts";
12
+ import { registerCodexCompatiblePayloadAdapter } from "../src/codex-compat.ts";
13
+
14
+ const extensionDir = dirname(fileURLToPath(import.meta.url));
15
+ const packageRoot = dirname(extensionDir);
16
+ const bundledModelsDevPath = join(packageRoot, "data", "models-dev-fallback.json");
17
+
18
+ export default async function (pi: ExtensionAPI) {
19
+ let config = DEFAULT_CONFIG;
20
+ try {
21
+ const cwd = process.cwd();
22
+ config = loadConfig(cwd);
23
+ const settings = loadProviderSettings(cwd);
24
+ const catalog = new ProviderCatalog({
25
+ config,
26
+ gpt56ContextWindow: settings.gpt56ContextWindow,
27
+ bundledModelsDevPath,
28
+ getApiKey: () => getDiscoveryApiKey(config.providerName),
29
+ });
30
+ const runtime = new ProviderRuntime({ pi, config, catalog });
31
+ registerCodexCompatiblePayloadAdapter(pi, config.providerName);
32
+ registerCliproxyapiCommand(pi, runtime, catalog);
33
+ await runtime.start();
34
+ } catch (error) {
35
+ registerCodexCompatiblePayloadAdapter(pi, config.providerName);
36
+ registerCliproxyapiCommand(pi);
37
+ pi.registerProvider(config.providerName, buildProviderRegistration(config, buildUnavailableProviderModels()).config);
38
+ console.warn(`[pi-cliproxyapi-provider] registered placeholder provider after startup failure: ${error instanceof Error ? error.message : String(error)}`);
39
+ }
40
+ }
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@samrito/pi-cliproxyapi-provider",
3
+ "version": "0.16.0",
4
+ "description": "Pi provider package for CLIProxyAPI with automatic model discovery and models.dev enrichment.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "cliproxyapi",
13
+ "provider",
14
+ "models.dev"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/xiangsam/pi-cliproxyapi-provider.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/xiangsam/pi-cliproxyapi-provider/issues"
22
+ },
23
+ "homepage": "https://github.com/xiangsam/pi-cliproxyapi-provider#readme",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "extensions",
30
+ "data",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "pi": {
35
+ "extensions": [
36
+ "./extensions"
37
+ ]
38
+ },
39
+ "scripts": {
40
+ "test": "node --test test/*.test.ts",
41
+ "test:unit": "node --test test/*.test.ts",
42
+ "typecheck": "tsc --noEmit",
43
+ "check": "npm run typecheck && npm test",
44
+ "update:models-dev": "node scripts/update-models-dev-fallback.mjs"
45
+ },
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-ai": ">=0.84.0",
48
+ "@earendil-works/pi-coding-agent": ">=0.84.0",
49
+ "@earendil-works/pi-tui": ">=0.84.0"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@earendil-works/pi-coding-agent": {
53
+ "optional": true
54
+ },
55
+ "@earendil-works/pi-ai": {
56
+ "optional": true
57
+ },
58
+ "@earendil-works/pi-tui": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "devDependencies": {
63
+ "@earendil-works/pi-ai": "0.84.1",
64
+ "@earendil-works/pi-coding-agent": "0.84.1",
65
+ "@earendil-works/pi-tui": "0.84.1",
66
+ "typescript": "^5.9.3"
67
+ }
68
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { readStoredCredential } from "@earendil-works/pi-coding-agent";
2
+
3
+ export async function getDiscoveryApiKey(providerName: string, env: NodeJS.ProcessEnv = process.env): Promise<string | undefined> {
4
+ try {
5
+ const credential = await readStoredCredential(providerName);
6
+ if (credential?.type === "api_key") return credential.key;
7
+ return env.CLIPROXYAPI_API_KEY;
8
+ } catch {
9
+ return env.CLIPROXYAPI_API_KEY;
10
+ }
11
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ export interface CacheEnvelope<T> {
5
+ fetchedAt: number;
6
+ data: T;
7
+ }
8
+
9
+ export async function readCache<T>(path: string, parseData: (value: unknown) => T): Promise<CacheEnvelope<T> | undefined> {
10
+ try {
11
+ const parsed = JSON.parse(await readFile(path, "utf8"));
12
+ if (!parsed || typeof parsed !== "object" || typeof parsed.fetchedAt !== "number" || !("data" in parsed)) {
13
+ return undefined;
14
+ }
15
+ return { fetchedAt: parsed.fetchedAt, data: parseData(parsed.data) };
16
+ } catch (error) {
17
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ export async function writeCache<T>(path: string, data: T, fetchedAt = Date.now()): Promise<void> {
23
+ const directory = dirname(path);
24
+ await mkdir(directory, { recursive: true, mode: 0o700 });
25
+ const temporaryPath = join(directory, `.${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
26
+ try {
27
+ await writeFile(temporaryPath, `${JSON.stringify({ fetchedAt, data }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
28
+ await rename(temporaryPath, path);
29
+ await chmod(path, 0o600);
30
+ } finally {
31
+ await rm(temporaryPath, { force: true });
32
+ }
33
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,248 @@
1
+ import { cpaModelsCachePath, discoveryHeaders, modelsDevCachePath } from "./discovery.ts";
2
+ import { readCache, writeCache, type CacheEnvelope } from "./cache.ts";
3
+ import { fetchCpaModels, parseCpaModelsCache, type CpaModel } from "./cpa.ts";
4
+ import { fetchModelsDevCatalog, hasSourceProviderMetadata, parseModelsDevCatalog, readBundledModelsDevFallback } from "./models-dev.ts";
5
+ import { buildProviderModels, type BuildProviderModelsResult } from "./provider.ts";
6
+ import type { Gpt56ContextWindowMode } from "./settings.ts";
7
+ import type { CpaProviderConfig, ModelsDevCatalog } from "./types.ts";
8
+
9
+ export type MetadataSource = "cache" | "bundled" | "disabled";
10
+ export type RefreshTarget = "models" | "metadata" | "all";
11
+
12
+ export interface CatalogSnapshot {
13
+ cpaModels: CpaModel[];
14
+ cpaUpdatedAt?: number;
15
+ metadata: ModelsDevCatalog;
16
+ metadataUpdatedAt?: number;
17
+ metadataSource: MetadataSource;
18
+ gpt56ContextWindow: Gpt56ContextWindowMode;
19
+ built: BuildProviderModelsResult;
20
+ }
21
+
22
+ export interface SourceRefreshResult {
23
+ attempted: boolean;
24
+ updated: boolean;
25
+ changed: boolean;
26
+ error?: unknown;
27
+ }
28
+
29
+ export interface CatalogRefreshResult {
30
+ snapshot: CatalogSnapshot;
31
+ models: SourceRefreshResult;
32
+ metadata: SourceRefreshResult;
33
+ }
34
+
35
+ export interface ProviderCatalogOptions {
36
+ config: CpaProviderConfig;
37
+ gpt56ContextWindow: Gpt56ContextWindowMode;
38
+ bundledModelsDevPath: string;
39
+ getApiKey: () => Promise<string | undefined>;
40
+ backgroundTimeoutMs?: number;
41
+ manualTimeoutMs?: number;
42
+ writeSnapshot?: typeof writeCache;
43
+ }
44
+
45
+ function canonicalJson(value: unknown): string {
46
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
47
+ if (value && typeof value === "object") {
48
+ const entries = Object.entries(value as Record<string, unknown>)
49
+ .sort(([left], [right]) => left.localeCompare(right))
50
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`);
51
+ return `{${entries.join(",")}}`;
52
+ }
53
+ return JSON.stringify(value);
54
+ }
55
+
56
+ function sameCpaModels(left: CpaModel[], right: CpaModel[]): boolean {
57
+ return canonicalJson(left) === canonicalJson(right);
58
+ }
59
+
60
+ function sameMetadata(left: ModelsDevCatalog, right: ModelsDevCatalog): boolean {
61
+ return canonicalJson(left) === canonicalJson(right);
62
+ }
63
+
64
+ export class ProviderCatalog {
65
+ private snapshot?: CatalogSnapshot;
66
+ private activeRefresh?: Promise<CatalogRefreshResult>;
67
+ private activeRefreshTarget?: RefreshTarget;
68
+ private activeRefreshMode?: "background" | "manual";
69
+ private activeRefreshController?: AbortController;
70
+ private readonly activeRefreshWaiters = new Set<symbol>();
71
+ private readonly options: ProviderCatalogOptions;
72
+
73
+ constructor(options: ProviderCatalogOptions) {
74
+ this.options = options;
75
+ }
76
+
77
+ async load(): Promise<CatalogSnapshot> {
78
+ const cpaCache = await readCache(cpaModelsCachePath(this.options.config), parseCpaModelsCache);
79
+ const metadataSnapshot = await this.loadMetadata();
80
+ return this.setSnapshot(cpaCache?.data ?? [], cpaCache?.fetchedAt, metadataSnapshot.data, metadataSnapshot.fetchedAt, metadataSnapshot.source);
81
+ }
82
+
83
+ async refresh(
84
+ target: RefreshTarget = "all",
85
+ mode: "background" | "manual" = "manual",
86
+ getDiscoveryApiKey?: () => Promise<string | undefined>,
87
+ signal?: AbortSignal,
88
+ ): Promise<CatalogRefreshResult> {
89
+ if (this.activeRefresh) {
90
+ if (this.activeRefreshTarget === target && this.activeRefreshMode === mode) {
91
+ return this.waitForActiveRefresh(signal);
92
+ }
93
+ await this.activeRefresh;
94
+ }
95
+
96
+ const controller = new AbortController();
97
+ this.activeRefreshTarget = target;
98
+ this.activeRefreshMode = mode;
99
+ this.activeRefreshController = controller;
100
+ this.activeRefresh = this.performRefresh(target, mode, getDiscoveryApiKey, controller.signal).finally(() => {
101
+ this.activeRefresh = undefined;
102
+ this.activeRefreshTarget = undefined;
103
+ this.activeRefreshMode = undefined;
104
+ this.activeRefreshController = undefined;
105
+ this.activeRefreshWaiters.clear();
106
+ });
107
+ return this.waitForActiveRefresh(signal);
108
+ }
109
+
110
+ current(): CatalogSnapshot | undefined {
111
+ return this.snapshot;
112
+ }
113
+
114
+ private async waitForActiveRefresh(signal?: AbortSignal): Promise<CatalogRefreshResult> {
115
+ const refresh = this.activeRefresh;
116
+ if (!refresh) throw new Error("No active refresh");
117
+ if (signal?.aborted) throw signal.reason ?? new Error("Refresh aborted");
118
+
119
+ const waiter = Symbol("refresh-waiter");
120
+ this.activeRefreshWaiters.add(waiter);
121
+ let onAbort: (() => void) | undefined;
122
+ const aborted = signal
123
+ ? new Promise<never>((_resolve, reject) => {
124
+ onAbort = () => {
125
+ this.activeRefreshWaiters.delete(waiter);
126
+ if (this.activeRefreshWaiters.size === 0) {
127
+ this.activeRefreshController?.abort(signal.reason ?? new Error("Refresh aborted"));
128
+ }
129
+ reject(signal.reason ?? new Error("Refresh aborted"));
130
+ };
131
+ signal.addEventListener("abort", onAbort, { once: true });
132
+ })
133
+ : undefined;
134
+
135
+ try {
136
+ return await (aborted ? Promise.race([refresh, aborted]) : refresh);
137
+ } finally {
138
+ this.activeRefreshWaiters.delete(waiter);
139
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
140
+ }
141
+ }
142
+
143
+ private async performRefresh(
144
+ target: RefreshTarget,
145
+ mode: "background" | "manual",
146
+ getDiscoveryApiKey?: () => Promise<string | undefined>,
147
+ signal?: AbortSignal,
148
+ ): Promise<CatalogRefreshResult> {
149
+ const current = this.snapshot ?? await this.load();
150
+ let cpaModels = current.cpaModels;
151
+ let cpaUpdatedAt = current.cpaUpdatedAt;
152
+ let metadata = current.metadata;
153
+ let metadataUpdatedAt = current.metadataUpdatedAt;
154
+ let metadataSource = current.metadataSource;
155
+
156
+ const models: SourceRefreshResult = { attempted: target !== "metadata", updated: false, changed: false };
157
+ const metadataResult: SourceRefreshResult = { attempted: target !== "models" && this.options.config.modelsDevEnabled, updated: false, changed: false };
158
+
159
+ if (models.attempted) {
160
+ try {
161
+ const apiKey = await (getDiscoveryApiKey ?? this.options.getApiKey)();
162
+ const fresh = await fetchCpaModels(
163
+ this.options.config.baseUrl,
164
+ discoveryHeaders(this.options.config, apiKey),
165
+ mode === "background" ? this.options.backgroundTimeoutMs ?? 2_000 : this.options.manualTimeoutMs ?? 10_000,
166
+ signal,
167
+ );
168
+ if (mode === "background" && current.cpaModels.length > 0 && fresh.length === 0) {
169
+ throw new Error("CPA automatic discovery returned no models; retained the last successful snapshot");
170
+ }
171
+ const freshUpdatedAt = Date.now();
172
+ const changed = !sameCpaModels(current.cpaModels, fresh);
173
+ await (this.options.writeSnapshot ?? writeCache)(cpaModelsCachePath(this.options.config), fresh, freshUpdatedAt);
174
+ cpaModels = fresh;
175
+ cpaUpdatedAt = freshUpdatedAt;
176
+ models.changed = changed;
177
+ models.updated = true;
178
+ } catch (error) {
179
+ if (signal?.aborted) throw signal.reason ?? error;
180
+ models.error = error;
181
+ }
182
+ }
183
+
184
+ if (metadataResult.attempted) {
185
+ try {
186
+ const fresh = await fetchModelsDevCatalog(this.options.manualTimeoutMs ?? 10_000, signal);
187
+ const freshUpdatedAt = Date.now();
188
+ const changed = !sameMetadata(current.metadata, fresh);
189
+ await (this.options.writeSnapshot ?? writeCache)(modelsDevCachePath(), fresh, freshUpdatedAt);
190
+ metadata = fresh;
191
+ metadataUpdatedAt = freshUpdatedAt;
192
+ metadataSource = "cache";
193
+ metadataResult.changed = changed;
194
+ metadataResult.updated = true;
195
+ } catch (error) {
196
+ if (signal?.aborted) throw signal.reason ?? error;
197
+ metadataResult.error = error;
198
+ }
199
+ }
200
+
201
+ const snapshot = this.setSnapshot(cpaModels, cpaUpdatedAt, metadata, metadataUpdatedAt, metadataSource);
202
+ return { snapshot, models, metadata: metadataResult };
203
+ }
204
+
205
+ private async loadMetadata(): Promise<{ data: ModelsDevCatalog; fetchedAt?: number; source: MetadataSource }> {
206
+ if (!this.options.config.modelsDevEnabled) return { data: {}, source: "disabled" };
207
+ const cached = await readCache(modelsDevCachePath(), parseModelsDevCatalog);
208
+ if (cached && hasSourceProviderMetadata(cached.data)) {
209
+ return { data: cached.data, fetchedAt: cached.fetchedAt, source: "cache" };
210
+ }
211
+ return { data: await readBundledModelsDevFallback(this.options.bundledModelsDevPath), source: "bundled" };
212
+ }
213
+
214
+ private setSnapshot(
215
+ cpaModels: CpaModel[],
216
+ cpaUpdatedAt: number | undefined,
217
+ metadata: ModelsDevCatalog,
218
+ metadataUpdatedAt: number | undefined,
219
+ metadataSource: MetadataSource,
220
+ ): CatalogSnapshot {
221
+ this.snapshot = {
222
+ cpaModels,
223
+ cpaUpdatedAt,
224
+ metadata,
225
+ metadataUpdatedAt,
226
+ metadataSource,
227
+ gpt56ContextWindow: this.options.gpt56ContextWindow,
228
+ built: buildProviderModels(
229
+ cpaModels,
230
+ metadata,
231
+ this.options.config.modelAliases,
232
+ this.options.gpt56ContextWindow,
233
+ this.options.config.modelOverrides,
234
+ this.options.config.metadataFallbackProvider,
235
+ ),
236
+ };
237
+ return this.snapshot;
238
+ }
239
+ }
240
+
241
+ export function cacheAge(envelope: Pick<CacheEnvelope<unknown>, "fetchedAt"> | undefined, now = Date.now()): string {
242
+ if (!envelope) return "missing";
243
+ const seconds = Math.max(0, Math.round((now - envelope.fetchedAt) / 1000));
244
+ if (seconds < 60) return `${seconds}s ago`;
245
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
246
+ if (seconds < 86_400) return `${Math.round(seconds / 3600)}h ago`;
247
+ return `${Math.round(seconds / 86_400)}d ago`;
248
+ }
@@ -0,0 +1,43 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ function isRecord(value: unknown): value is Record<string, unknown> {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+
7
+ function isResponsesModel(
8
+ model: Pick<NonNullable<ExtensionContext["model"]>, "provider" | "api"> | null | undefined,
9
+ providerName: string,
10
+ ): boolean {
11
+ return model?.provider.trim().toLowerCase() === providerName.trim().toLowerCase()
12
+ && model.api.toLowerCase().includes("responses");
13
+ }
14
+
15
+ /**
16
+ * Match the Codex-compatible Responses wire contract used by
17
+ * pi-codex-conversion: function tools explicitly publish `strict: null`.
18
+ * Generic OpenAI Responses omits the field when strict mode is unsupported,
19
+ * which does not preserve optional argument behavior for Codex-like models.
20
+ */
21
+ export function rewriteCodexCompatibleToolPayload(
22
+ payload: unknown,
23
+ model: Pick<NonNullable<ExtensionContext["model"]>, "provider" | "api"> | null | undefined,
24
+ providerName: string,
25
+ ): unknown | undefined {
26
+ if (!isResponsesModel(model, providerName) || !isRecord(payload) || !Array.isArray(payload.tools)) {
27
+ return undefined;
28
+ }
29
+
30
+ let changed = false;
31
+ const tools = payload.tools.map((tool) => {
32
+ if (!isRecord(tool) || tool.type !== "function" || tool.strict === null) return tool;
33
+ changed = true;
34
+ return { ...tool, strict: null };
35
+ });
36
+
37
+ return changed ? { ...payload, tools } : undefined;
38
+ }
39
+
40
+ export function registerCodexCompatiblePayloadAdapter(pi: ExtensionAPI, providerName: string): void {
41
+ pi.on("before_provider_request", (event, ctx) =>
42
+ rewriteCodexCompatibleToolPayload(event.payload, ctx.model, providerName));
43
+ }
@@ -0,0 +1,213 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { DEFAULT_CONFIG, loadConfig, globalConfigPath, readConfigFile, writeConfigFile, type ConfigLayer } from "./config.ts";
3
+ import type { ProviderCatalog, CatalogSnapshot, RefreshTarget, SourceRefreshResult } from "./catalog.ts";
4
+ import type { ProviderRuntime } from "./runtime.ts";
5
+ import { openModelInspector, openProviderConfig } from "./model-ui.ts";
6
+ import { loadProviderSettings } from "./settings.ts";
7
+
8
+ function errorText(error: unknown): string {
9
+ return error instanceof Error ? error.message : String(error);
10
+ }
11
+
12
+ export function formatStatusFailure(config: ReturnType<typeof loadConfig>, error: unknown): string {
13
+ return [
14
+ `CLIProxyAPI status failed: ${errorText(error)}`,
15
+ `Provider: ${config.providerName}`,
16
+ `Base URL: ${config.baseUrl}`,
17
+ `Auth required: ${config.authRequired ? "yes" : "no"}`,
18
+ "",
19
+ "Run /cliproxyapi config to set the CLIProxyAPI base URL.",
20
+ `If you just ran /login ${config.providerName}, run /cliproxyapi refresh models.`,
21
+ ].join("\n");
22
+ }
23
+
24
+ function redactConfig(value: unknown): unknown {
25
+ if (Array.isArray(value)) return value.map(redactConfig);
26
+ if (!value || typeof value !== "object") return value;
27
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => {
28
+ const isSecretLike = /authorization|api[-_]?key|token|secret|cookie/i.test(key);
29
+ return [key, isSecretLike ? "<redacted>" : redactConfig(entry)];
30
+ }));
31
+ }
32
+
33
+ function age(timestamp?: number): string {
34
+ if (timestamp === undefined) return "missing";
35
+ const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
36
+ if (seconds < 60) return `${seconds}s ago`;
37
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
38
+ if (seconds < 86_400) return `${Math.round(seconds / 3600)}h ago`;
39
+ return `${Math.round(seconds / 86_400)}d ago`;
40
+ }
41
+
42
+ function capabilityCount(snapshot: CatalogSnapshot, key: "reasoning" | "image"): number {
43
+ return snapshot.built.models.filter((model) => key === "reasoning" ? model.reasoning : model.input.includes("image")).length;
44
+ }
45
+
46
+ export async function runConfig(ctx: ExtensionCommandContext): Promise<void> {
47
+ if (!ctx.hasUI) {
48
+ ctx.ui.notify("/cliproxyapi config connection requires an interactive UI.", "warning");
49
+ return;
50
+ }
51
+
52
+ let current = DEFAULT_CONFIG;
53
+ try {
54
+ current = loadConfig(ctx.cwd);
55
+ } catch (error) {
56
+ ctx.ui.notify(`Existing CLIProxyAPI config is invalid; using defaults for repair: ${errorText(error)}`, "warning");
57
+ }
58
+
59
+ const path = globalConfigPath();
60
+ let existing: ConfigLayer | undefined;
61
+ try {
62
+ existing = readConfigFile(path);
63
+ } catch (error) {
64
+ ctx.ui.notify(`Existing global CLIProxyAPI config is invalid and will be replaced if you save: ${errorText(error)}`, "warning");
65
+ }
66
+ const defaults = existing ?? current;
67
+
68
+ if (existing) ctx.ui.notify(`Editing existing global config at ${path}:\n${JSON.stringify(redactConfig(existing), null, 2)}`, "info");
69
+
70
+ const providerNameInput = await ctx.ui.input(`Provider name (leave blank to keep: ${defaults.providerName})`, `leave blank to keep ${defaults.providerName}`);
71
+ if (providerNameInput === undefined) return;
72
+ const baseUrlInput = await ctx.ui.input(`CLIProxyAPI base URL (leave blank to keep: ${defaults.baseUrl})`, `leave blank to keep ${defaults.baseUrl}`);
73
+ if (baseUrlInput === undefined) return;
74
+ const authRequired = await ctx.ui.confirm(
75
+ `Require /login credentials? (current: ${defaults.authRequired ? "yes" : "no"})`,
76
+ "Choose yes unless this CLIProxyAPI instance accepts unauthenticated requests.",
77
+ );
78
+ const authHeader = authRequired
79
+ ? await ctx.ui.confirm(
80
+ `Send Authorization bearer header? (current: ${defaults.authHeader ? "yes" : "no"})`,
81
+ "Choose yes for CLIProxyAPI API keys.",
82
+ )
83
+ : false;
84
+
85
+ writeConfigFile(path, {
86
+ ...existing,
87
+ providerName: providerNameInput || defaults.providerName,
88
+ baseUrl: baseUrlInput || defaults.baseUrl,
89
+ authRequired,
90
+ authHeader,
91
+ });
92
+ ctx.ui.notify(`Saved CLIProxyAPI config to ${path}. Reloading pi to apply connection changes...`, "info");
93
+ await ctx.reload();
94
+ }
95
+
96
+ function statusText(config: ReturnType<typeof loadConfig>, snapshot: CatalogSnapshot): string {
97
+ return [
98
+ `CLIProxyAPI provider: ${config.providerName}`,
99
+ `Base URL: ${config.baseUrl}`,
100
+ `Auth required: ${config.authRequired ? "yes" : "no"}`,
101
+ `Models: ${snapshot.built.stats.total} (${snapshot.built.stats.enriched} enriched, ${snapshot.built.stats.unmatched} unmatched)`,
102
+ `Reasoning models: ${capabilityCount(snapshot, "reasoning")}`,
103
+ `Image-capable models: ${capabilityCount(snapshot, "image")}`,
104
+ `CPA snapshot: ${age(snapshot.cpaUpdatedAt)}`,
105
+ `models.dev metadata: ${snapshot.metadataSource}${snapshot.metadataUpdatedAt ? `, ${age(snapshot.metadataUpdatedAt)}` : ""}`,
106
+ `GPT-5.6 context window: ${snapshot.gpt56ContextWindow === "full" ? "full models.dev limit" : "canonical 272000"}`,
107
+ ].join("\n");
108
+ }
109
+
110
+ function refreshPart(label: string, result: SourceRefreshResult): string {
111
+ if (!result.attempted) return `${label}: not requested`;
112
+ if (result.error) return `${label}: failed (${errorText(result.error)}); retained previous snapshot`;
113
+ return `${label}: ${result.changed ? "updated" : "unchanged"}`;
114
+ }
115
+
116
+ function parseRefreshTarget(value: string | undefined): RefreshTarget | undefined {
117
+ if (!value || value === "all") return "all";
118
+ if (value === "models") return "models";
119
+ if (value === "metadata") return "metadata";
120
+ return undefined;
121
+ }
122
+
123
+ const CLIPROXYAPI_HELP = [
124
+ "CLIProxyAPI provider commands:",
125
+ " /cliproxyapi status Show provider and model-catalog status",
126
+ " /cliproxyapi refresh Refresh CPA models and models.dev metadata",
127
+ " /cliproxyapi refresh models Refresh CPA models only",
128
+ " /cliproxyapi refresh metadata Refresh models.dev metadata only",
129
+ " /cliproxyapi aliases Show unmatched model IDs for alias configuration",
130
+ " /cliproxyapi models Inspect models and set bounded overrides",
131
+ " /cliproxyapi config Configure model behavior and display",
132
+ " /cliproxyapi config connection Configure provider endpoint and auth",
133
+ " /cliproxyapi help Show this help",
134
+ ].join("\n");
135
+
136
+ export function cliproxyapiArgumentCompletions(prefix: string): Array<{ value: string; label: string }> {
137
+ return ["config", "config connection", "status", "refresh", "refresh models", "refresh metadata", "aliases", "models", "help"]
138
+ .filter((item) => item.startsWith(prefix))
139
+ .map((value) => ({ value, label: value }));
140
+ }
141
+
142
+ export function registerCliproxyapiCommand(pi: ExtensionAPI, runtime?: ProviderRuntime, catalog?: ProviderCatalog): void {
143
+ pi.registerCommand("cliproxyapi", {
144
+ description: "Configure, refresh, and inspect the CLIProxyAPI provider.",
145
+ getArgumentCompletions(prefix) {
146
+ return cliproxyapiArgumentCompletions(prefix);
147
+ },
148
+ async handler(args, ctx) {
149
+ const commandArgs = args.trim();
150
+ const [subcommand, option] = commandArgs ? commandArgs.split(/\s+/) : ["help"];
151
+ if (subcommand === "help") {
152
+ ctx.ui.notify(CLIPROXYAPI_HELP, "info");
153
+ return;
154
+ }
155
+ if (subcommand === "config") {
156
+ if (option === "connection") return runConfig(ctx);
157
+ if (option) {
158
+ ctx.ui.notify("Usage: /cliproxyapi config [connection]", "warning");
159
+ return;
160
+ }
161
+ const action = await openProviderConfig(ctx, loadProviderSettings(ctx.cwd), loadConfig(ctx.cwd));
162
+ if (action === "connection") return runConfig(ctx);
163
+ return;
164
+ }
165
+ if (!["status", "refresh", "aliases", "models"].includes(subcommand)) {
166
+ ctx.ui.notify(`${CLIPROXYAPI_HELP}\n\nUnknown command: ${subcommand}`, "warning");
167
+ return;
168
+ }
169
+ if (!runtime || !catalog) {
170
+ ctx.ui.notify("CLIProxyAPI provider is unavailable. Run /cliproxyapi config and reload pi.", "error");
171
+ return;
172
+ }
173
+ const config = loadConfig(ctx.cwd);
174
+ if (subcommand === "status") {
175
+ const snapshot = catalog.current() ?? await catalog.load();
176
+ ctx.ui.notify(statusText(config, snapshot), "info");
177
+ return;
178
+ }
179
+ if (subcommand === "refresh") {
180
+ const target = parseRefreshTarget(option);
181
+ if (!target) {
182
+ ctx.ui.notify("Usage: /cliproxyapi refresh [models|metadata]", "warning");
183
+ return;
184
+ }
185
+ const getDiscoveryApiKey = target === "metadata"
186
+ ? undefined
187
+ : () => ctx.modelRegistry.getApiKeyForProvider(config.providerName);
188
+ const result = await runtime.refresh(target, "manual", getDiscoveryApiKey);
189
+ const level = result.models.error || result.metadata.error ? "warning" : "info";
190
+ ctx.ui.notify([
191
+ "CLIProxyAPI provider refresh complete.",
192
+ refreshPart("CPA models", result.models),
193
+ refreshPart("models.dev metadata", result.metadata),
194
+ `Registered: ${result.snapshot.built.stats.total} models, ${result.snapshot.built.stats.enriched} enriched, ${result.snapshot.built.stats.unmatched} unmatched.`,
195
+ ].join("\n"), level);
196
+ return;
197
+ }
198
+ if (subcommand === "aliases") {
199
+ const snapshot = catalog.current() ?? await catalog.load();
200
+ const sample = snapshot.built.stats.unmatchedModelIds.slice(0, 30);
201
+ const body = sample.length === 0
202
+ ? "All CPA models matched models.dev metadata."
203
+ : `Unmatched CPA models (${snapshot.built.stats.unmatched}):\n${sample.map((id) => ` "${id}": "<models.dev-id>"`).join("\n")}`;
204
+ ctx.ui.notify(body, snapshot.built.stats.unmatched ? "warning" : "info");
205
+ return;
206
+ }
207
+ if (subcommand === "models") {
208
+ await openModelInspector(ctx, catalog, config.modelOverrides, loadProviderSettings(ctx.cwd).showStrictMode);
209
+ return;
210
+ }
211
+ },
212
+ });
213
+ }