pi-cliproxyapi-provider 0.1.0 → 0.2.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 +20 -10
- package/data/models-dev-fallback.json +1 -1
- package/extensions/index.ts +26 -9
- package/package.json +27 -8
- package/src/cache.ts +12 -34
- package/src/catalog.ts +185 -0
- package/src/commands.ts +80 -67
- package/src/config.ts +0 -9
- package/src/cpa.ts +21 -7
- package/src/discovery.ts +2 -82
- package/src/models-dev.ts +3 -0
- package/src/runtime.ts +41 -0
- package/src/types.ts +0 -2
package/extensions/index.ts
CHANGED
|
@@ -2,9 +2,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { DEFAULT_CONFIG, loadConfig } from "../src/config.ts";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { ProviderCatalog } from "../src/catalog.ts";
|
|
6
|
+
import { ProviderRuntime } from "../src/runtime.ts";
|
|
7
7
|
import { buildProviderRegistration } from "../src/registration.ts";
|
|
8
|
+
import { buildUnavailableProviderModels } from "../src/provider.ts";
|
|
8
9
|
import { registerCliproxyapiCommand } from "../src/commands.ts";
|
|
9
10
|
import { getDiscoveryApiKey } from "../src/auth.ts";
|
|
10
11
|
|
|
@@ -13,17 +14,33 @@ const packageRoot = dirname(extensionDir);
|
|
|
13
14
|
const bundledModelsDevPath = join(packageRoot, "data", "models-dev-fallback.json");
|
|
14
15
|
|
|
15
16
|
export default async function (pi: ExtensionAPI) {
|
|
16
|
-
registerCliproxyapiCommand(pi, bundledModelsDevPath);
|
|
17
|
-
|
|
18
17
|
let config = DEFAULT_CONFIG;
|
|
19
18
|
try {
|
|
20
19
|
config = loadConfig(process.cwd());
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
const catalog = new ProviderCatalog({
|
|
21
|
+
config,
|
|
22
|
+
bundledModelsDevPath,
|
|
23
|
+
getApiKey: () => getDiscoveryApiKey(config.providerName),
|
|
24
|
+
});
|
|
25
|
+
const runtime = new ProviderRuntime({ pi, config, catalog });
|
|
26
|
+
registerCliproxyapiCommand(pi, runtime, catalog);
|
|
27
|
+
await runtime.start();
|
|
28
|
+
|
|
29
|
+
let backgroundRefreshStarted = false;
|
|
30
|
+
pi.on("session_start", (event, ctx) => {
|
|
31
|
+
if (event.reason !== "startup" || backgroundRefreshStarted) return;
|
|
32
|
+
backgroundRefreshStarted = true;
|
|
33
|
+
void runtime.refresh("models", "background").then((result) => {
|
|
34
|
+
if (result.models.error && ctx.hasUI) {
|
|
35
|
+
ctx.ui.setStatus("cliproxyapi", "CPA: cached models");
|
|
36
|
+
} else if (ctx.hasUI) {
|
|
37
|
+
ctx.ui.setStatus("cliproxyapi", undefined);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
25
41
|
} catch (error) {
|
|
42
|
+
registerCliproxyapiCommand(pi);
|
|
26
43
|
pi.registerProvider(config.providerName, buildProviderRegistration(config, buildUnavailableProviderModels()).config);
|
|
27
|
-
console.warn(`[pi-cliproxyapi-provider] registered placeholder provider after
|
|
44
|
+
console.warn(`[pi-cliproxyapi-provider] registered placeholder provider after startup failure: ${error instanceof Error ? error.message : String(error)}`);
|
|
28
45
|
}
|
|
29
46
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cliproxyapi-provider",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pi provider package for CLIProxyAPI with automatic model discovery and models.dev enrichment.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
8
|
-
"keywords": [
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"cliproxyapi",
|
|
13
|
+
"provider",
|
|
14
|
+
"models.dev"
|
|
15
|
+
],
|
|
9
16
|
"repository": {
|
|
10
17
|
"type": "git",
|
|
11
18
|
"url": "git+https://github.com/0xRichardH/pi-cliproxyapi-provider.git"
|
|
@@ -14,17 +21,28 @@
|
|
|
14
21
|
"url": "https://github.com/0xRichardH/pi-cliproxyapi-provider/issues"
|
|
15
22
|
},
|
|
16
23
|
"homepage": "https://github.com/0xRichardH/pi-cliproxyapi-provider#readme",
|
|
17
|
-
"files": [
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"extensions",
|
|
27
|
+
"data",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
18
31
|
"pi": {
|
|
19
|
-
"extensions": [
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./extensions"
|
|
34
|
+
]
|
|
20
35
|
},
|
|
21
36
|
"scripts": {
|
|
22
37
|
"test": "node --test test/*.test.ts",
|
|
23
|
-
"test:unit": "node --test test/*.test.ts"
|
|
38
|
+
"test:unit": "node --test test/*.test.ts",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"check": "npm run typecheck && npm test",
|
|
41
|
+
"update:models-dev": "node scripts/update-models-dev-fallback.mjs"
|
|
24
42
|
},
|
|
25
43
|
"peerDependencies": {
|
|
26
|
-
"@earendil-works/pi-
|
|
27
|
-
"@earendil-works/pi-
|
|
44
|
+
"@earendil-works/pi-ai": "*",
|
|
45
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
28
46
|
},
|
|
29
47
|
"peerDependenciesMeta": {
|
|
30
48
|
"@earendil-works/pi-coding-agent": {
|
|
@@ -35,7 +53,8 @@
|
|
|
35
53
|
}
|
|
36
54
|
},
|
|
37
55
|
"devDependencies": {
|
|
56
|
+
"@earendil-works/pi-ai": "0.80.6",
|
|
38
57
|
"@earendil-works/pi-coding-agent": "0.80.6",
|
|
39
|
-
"
|
|
58
|
+
"typescript": "^5.9.3"
|
|
40
59
|
}
|
|
41
60
|
}
|
package/src/cache.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname } from "node:path";
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
export interface CacheEnvelope<T> {
|
|
5
5
|
fetchedAt: number;
|
|
6
6
|
data: T;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export async function readCache<T>(path: string): Promise<CacheEnvelope<T> | undefined> {
|
|
9
|
+
export async function readCache<T>(path: string, parseData: (value: unknown) => T): Promise<CacheEnvelope<T> | undefined> {
|
|
10
10
|
try {
|
|
11
11
|
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
12
12
|
if (!parsed || typeof parsed !== "object" || typeof parsed.fetchedAt !== "number" || !("data" in parsed)) {
|
|
13
13
|
return undefined;
|
|
14
14
|
}
|
|
15
|
-
return parsed
|
|
15
|
+
return { fetchedAt: parsed.fetchedAt, data: parseData(parsed.data) };
|
|
16
16
|
} catch (error) {
|
|
17
17
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
18
18
|
return undefined;
|
|
@@ -20,36 +20,14 @@ export async function readCache<T>(path: string): Promise<CacheEnvelope<T> | und
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export async function writeCache<T>(path: string, data: T, fetchedAt = Date.now()): Promise<void> {
|
|
23
|
-
|
|
24
|
-
await
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function isFresh(cache: Pick<CacheEnvelope<unknown>, "fetchedAt"> | undefined, ttlSeconds: number, now = Date.now()): boolean {
|
|
28
|
-
if (!cache || ttlSeconds <= 0 || cache.fetchedAt > now) return false;
|
|
29
|
-
return now - cache.fetchedAt < ttlSeconds * 1000;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function getCachedOrFetch<T>(options: {
|
|
33
|
-
path: string;
|
|
34
|
-
ttlSeconds: number;
|
|
35
|
-
fetchFresh: () => Promise<T>;
|
|
36
|
-
now?: number;
|
|
37
|
-
force?: boolean;
|
|
38
|
-
}): Promise<{ data: T; source: "fresh" | "cache" | "stale"; error?: unknown }> {
|
|
39
|
-
const cached = await readCache<T>(options.path);
|
|
40
|
-
const now = options.now ?? Date.now();
|
|
41
|
-
if (!options.force && isFresh(cached, options.ttlSeconds, now)) {
|
|
42
|
-
return { data: cached!.data, source: "cache" };
|
|
43
|
-
}
|
|
44
|
-
|
|
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`);
|
|
45
26
|
try {
|
|
46
|
-
|
|
47
|
-
await
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return { data: cached.data, source: "stale", error };
|
|
52
|
-
}
|
|
53
|
-
throw error;
|
|
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 });
|
|
54
32
|
}
|
|
55
33
|
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
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, parseModelsDevCatalog, readBundledModelsDevFallback } from "./models-dev.ts";
|
|
5
|
+
import { buildProviderModels, type BuildProviderModelsResult } from "./provider.ts";
|
|
6
|
+
import type { CpaProviderConfig, ModelsDevCatalog } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export type MetadataSource = "cache" | "bundled" | "disabled";
|
|
9
|
+
export type RefreshTarget = "models" | "metadata" | "all";
|
|
10
|
+
|
|
11
|
+
export interface CatalogSnapshot {
|
|
12
|
+
cpaModels: CpaModel[];
|
|
13
|
+
cpaUpdatedAt?: number;
|
|
14
|
+
metadata: ModelsDevCatalog;
|
|
15
|
+
metadataUpdatedAt?: number;
|
|
16
|
+
metadataSource: MetadataSource;
|
|
17
|
+
built: BuildProviderModelsResult;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SourceRefreshResult {
|
|
21
|
+
attempted: boolean;
|
|
22
|
+
updated: boolean;
|
|
23
|
+
changed: boolean;
|
|
24
|
+
error?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CatalogRefreshResult {
|
|
28
|
+
snapshot: CatalogSnapshot;
|
|
29
|
+
models: SourceRefreshResult;
|
|
30
|
+
metadata: SourceRefreshResult;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ProviderCatalogOptions {
|
|
34
|
+
config: CpaProviderConfig;
|
|
35
|
+
bundledModelsDevPath: string;
|
|
36
|
+
getApiKey: () => Promise<string | undefined>;
|
|
37
|
+
backgroundTimeoutMs?: number;
|
|
38
|
+
manualTimeoutMs?: number;
|
|
39
|
+
writeSnapshot?: typeof writeCache;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function canonicalJson(value: unknown): string {
|
|
43
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
44
|
+
if (value && typeof value === "object") {
|
|
45
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
46
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
47
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`);
|
|
48
|
+
return `{${entries.join(",")}}`;
|
|
49
|
+
}
|
|
50
|
+
return JSON.stringify(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sameCpaModels(left: CpaModel[], right: CpaModel[]): boolean {
|
|
54
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sameMetadata(left: ModelsDevCatalog, right: ModelsDevCatalog): boolean {
|
|
58
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class ProviderCatalog {
|
|
62
|
+
private snapshot?: CatalogSnapshot;
|
|
63
|
+
private activeRefresh?: Promise<CatalogRefreshResult>;
|
|
64
|
+
private activeRefreshTarget?: RefreshTarget;
|
|
65
|
+
private activeRefreshMode?: "background" | "manual";
|
|
66
|
+
private readonly options: ProviderCatalogOptions;
|
|
67
|
+
|
|
68
|
+
constructor(options: ProviderCatalogOptions) {
|
|
69
|
+
this.options = options;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async load(): Promise<CatalogSnapshot> {
|
|
73
|
+
const cpaCache = await readCache(cpaModelsCachePath(this.options.config), parseCpaModelsCache);
|
|
74
|
+
const metadataSnapshot = await this.loadMetadata();
|
|
75
|
+
return this.setSnapshot(cpaCache?.data ?? [], cpaCache?.fetchedAt, metadataSnapshot.data, metadataSnapshot.fetchedAt, metadataSnapshot.source);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async refresh(target: RefreshTarget = "all", mode: "background" | "manual" = "manual"): Promise<CatalogRefreshResult> {
|
|
79
|
+
if (this.activeRefresh) {
|
|
80
|
+
if (this.activeRefreshTarget === target && this.activeRefreshMode === mode) return this.activeRefresh;
|
|
81
|
+
await this.activeRefresh;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.activeRefreshTarget = target;
|
|
85
|
+
this.activeRefreshMode = mode;
|
|
86
|
+
this.activeRefresh = this.performRefresh(target, mode).finally(() => {
|
|
87
|
+
this.activeRefresh = undefined;
|
|
88
|
+
this.activeRefreshTarget = undefined;
|
|
89
|
+
this.activeRefreshMode = undefined;
|
|
90
|
+
});
|
|
91
|
+
return this.activeRefresh;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
current(): CatalogSnapshot | undefined {
|
|
95
|
+
return this.snapshot;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async performRefresh(target: RefreshTarget, mode: "background" | "manual"): Promise<CatalogRefreshResult> {
|
|
99
|
+
const current = this.snapshot ?? await this.load();
|
|
100
|
+
let cpaModels = current.cpaModels;
|
|
101
|
+
let cpaUpdatedAt = current.cpaUpdatedAt;
|
|
102
|
+
let metadata = current.metadata;
|
|
103
|
+
let metadataUpdatedAt = current.metadataUpdatedAt;
|
|
104
|
+
let metadataSource = current.metadataSource;
|
|
105
|
+
|
|
106
|
+
const models: SourceRefreshResult = { attempted: target !== "metadata", updated: false, changed: false };
|
|
107
|
+
const metadataResult: SourceRefreshResult = { attempted: target !== "models" && this.options.config.modelsDevEnabled, updated: false, changed: false };
|
|
108
|
+
|
|
109
|
+
if (models.attempted) {
|
|
110
|
+
try {
|
|
111
|
+
const apiKey = await this.options.getApiKey();
|
|
112
|
+
const fresh = await fetchCpaModels(
|
|
113
|
+
this.options.config.baseUrl,
|
|
114
|
+
discoveryHeaders(this.options.config, apiKey),
|
|
115
|
+
mode === "background" ? this.options.backgroundTimeoutMs ?? 2_000 : this.options.manualTimeoutMs ?? 10_000,
|
|
116
|
+
);
|
|
117
|
+
if (mode === "background" && current.cpaModels.length > 0 && fresh.length === 0) {
|
|
118
|
+
throw new Error("CPA automatic discovery returned no models; retained the last successful snapshot");
|
|
119
|
+
}
|
|
120
|
+
const freshUpdatedAt = Date.now();
|
|
121
|
+
const changed = !sameCpaModels(current.cpaModels, fresh);
|
|
122
|
+
await (this.options.writeSnapshot ?? writeCache)(cpaModelsCachePath(this.options.config), fresh, freshUpdatedAt);
|
|
123
|
+
cpaModels = fresh;
|
|
124
|
+
cpaUpdatedAt = freshUpdatedAt;
|
|
125
|
+
models.changed = changed;
|
|
126
|
+
models.updated = true;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
models.error = error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (metadataResult.attempted) {
|
|
133
|
+
try {
|
|
134
|
+
const fresh = await fetchModelsDevCatalog(this.options.manualTimeoutMs ?? 10_000);
|
|
135
|
+
const freshUpdatedAt = Date.now();
|
|
136
|
+
const changed = !sameMetadata(current.metadata, fresh);
|
|
137
|
+
await (this.options.writeSnapshot ?? writeCache)(modelsDevCachePath(), fresh, freshUpdatedAt);
|
|
138
|
+
metadata = fresh;
|
|
139
|
+
metadataUpdatedAt = freshUpdatedAt;
|
|
140
|
+
metadataSource = "cache";
|
|
141
|
+
metadataResult.changed = changed;
|
|
142
|
+
metadataResult.updated = true;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
metadataResult.error = error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const snapshot = this.setSnapshot(cpaModels, cpaUpdatedAt, metadata, metadataUpdatedAt, metadataSource);
|
|
149
|
+
return { snapshot, models, metadata: metadataResult };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async loadMetadata(): Promise<{ data: ModelsDevCatalog; fetchedAt?: number; source: MetadataSource }> {
|
|
153
|
+
if (!this.options.config.modelsDevEnabled) return { data: {}, source: "disabled" };
|
|
154
|
+
const cached = await readCache(modelsDevCachePath(), parseModelsDevCatalog);
|
|
155
|
+
if (cached) return { data: cached.data, fetchedAt: cached.fetchedAt, source: "cache" };
|
|
156
|
+
return { data: await readBundledModelsDevFallback(this.options.bundledModelsDevPath), source: "bundled" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private setSnapshot(
|
|
160
|
+
cpaModels: CpaModel[],
|
|
161
|
+
cpaUpdatedAt: number | undefined,
|
|
162
|
+
metadata: ModelsDevCatalog,
|
|
163
|
+
metadataUpdatedAt: number | undefined,
|
|
164
|
+
metadataSource: MetadataSource,
|
|
165
|
+
): CatalogSnapshot {
|
|
166
|
+
this.snapshot = {
|
|
167
|
+
cpaModels,
|
|
168
|
+
cpaUpdatedAt,
|
|
169
|
+
metadata,
|
|
170
|
+
metadataUpdatedAt,
|
|
171
|
+
metadataSource,
|
|
172
|
+
built: buildProviderModels(cpaModels, metadata, this.options.config.modelAliases),
|
|
173
|
+
};
|
|
174
|
+
return this.snapshot;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function cacheAge(envelope: Pick<CacheEnvelope<unknown>, "fetchedAt"> | undefined, now = Date.now()): string {
|
|
179
|
+
if (!envelope) return "missing";
|
|
180
|
+
const seconds = Math.max(0, Math.round((now - envelope.fetchedAt) / 1000));
|
|
181
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
182
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
|
|
183
|
+
if (seconds < 86_400) return `${Math.round(seconds / 3600)}h ago`;
|
|
184
|
+
return `${Math.round(seconds / 86_400)}d ago`;
|
|
185
|
+
}
|
package/src/commands.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { existsSync, statSync } from "node:fs";
|
|
3
2
|
import { DEFAULT_CONFIG, loadConfig, globalConfigPath, readConfigFile, writeConfigFile, type ConfigLayer } from "./config.ts";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { getDiscoveryApiKey } from "./auth.ts";
|
|
3
|
+
import type { ProviderCatalog, CatalogSnapshot, RefreshTarget, SourceRefreshResult } from "./catalog.ts";
|
|
4
|
+
import type { ProviderRuntime } from "./runtime.ts";
|
|
7
5
|
|
|
8
6
|
function errorText(error: unknown): string {
|
|
9
7
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -17,26 +15,30 @@ export function formatStatusFailure(config: ReturnType<typeof loadConfig>, error
|
|
|
17
15
|
`Auth required: ${config.authRequired ? "yes" : "no"}`,
|
|
18
16
|
"",
|
|
19
17
|
"Run /cliproxyapi config to set the CLIProxyAPI base URL.",
|
|
20
|
-
`If you
|
|
18
|
+
`If you just ran /login ${config.providerName}, run /cliproxyapi refresh models.`,
|
|
21
19
|
].join("\n");
|
|
22
20
|
}
|
|
23
21
|
|
|
24
22
|
function redactConfig(value: unknown): unknown {
|
|
25
23
|
if (Array.isArray(value)) return value.map(redactConfig);
|
|
26
24
|
if (!value || typeof value !== "object") return value;
|
|
27
|
-
|
|
28
25
|
return Object.fromEntries(Object.entries(value).map(([key, entry]) => {
|
|
29
26
|
const isSecretLike = /authorization|api[-_]?key|token|secret|cookie/i.test(key);
|
|
30
27
|
return [key, isSecretLike ? "<redacted>" : redactConfig(entry)];
|
|
31
28
|
}));
|
|
32
29
|
}
|
|
33
30
|
|
|
34
|
-
function age(
|
|
35
|
-
if (
|
|
36
|
-
const seconds = Math.round((Date.now() -
|
|
37
|
-
if (seconds < 60) return `${seconds}s`;
|
|
38
|
-
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
|
39
|
-
return `${Math.round(seconds / 3600)}h`;
|
|
31
|
+
function age(timestamp?: number): string {
|
|
32
|
+
if (timestamp === undefined) return "missing";
|
|
33
|
+
const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
|
|
34
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
35
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
|
|
36
|
+
if (seconds < 86_400) return `${Math.round(seconds / 3600)}h ago`;
|
|
37
|
+
return `${Math.round(seconds / 86_400)}d ago`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function capabilityCount(snapshot: CatalogSnapshot, key: "reasoning" | "image"): number {
|
|
41
|
+
return snapshot.built.models.filter((model) => key === "reasoning" ? model.reasoning : model.input.includes("image")).length;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export async function runConfig(ctx: ExtensionCommandContext): Promise<void> {
|
|
@@ -61,9 +63,7 @@ export async function runConfig(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
61
63
|
}
|
|
62
64
|
const defaults = existing ?? current;
|
|
63
65
|
|
|
64
|
-
if (existing) {
|
|
65
|
-
ctx.ui.notify(`Editing existing global config at ${path}:\n${JSON.stringify(redactConfig(existing), null, 2)}`, "info");
|
|
66
|
-
}
|
|
66
|
+
if (existing) ctx.ui.notify(`Editing existing global config at ${path}:\n${JSON.stringify(redactConfig(existing), null, 2)}`, "info");
|
|
67
67
|
|
|
68
68
|
const providerNameInput = await ctx.ui.input(`Provider name (leave blank to keep: ${defaults.providerName})`, `leave blank to keep ${defaults.providerName}`);
|
|
69
69
|
if (providerNameInput === undefined) return;
|
|
@@ -71,89 +71,102 @@ export async function runConfig(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
71
71
|
if (baseUrlInput === undefined) return;
|
|
72
72
|
const authRequired = await ctx.ui.confirm(
|
|
73
73
|
`Require /login credentials? (current: ${defaults.authRequired ? "yes" : "no"})`,
|
|
74
|
-
"Choose yes unless this CLIProxyAPI instance accepts unauthenticated requests."
|
|
74
|
+
"Choose yes unless this CLIProxyAPI instance accepts unauthenticated requests.",
|
|
75
75
|
);
|
|
76
76
|
const authHeader = authRequired
|
|
77
77
|
? await ctx.ui.confirm(
|
|
78
78
|
`Send Authorization bearer header? (current: ${defaults.authHeader ? "yes" : "no"})`,
|
|
79
|
-
"Choose yes for CLIProxyAPI API keys."
|
|
79
|
+
"Choose yes for CLIProxyAPI API keys.",
|
|
80
80
|
)
|
|
81
81
|
: false;
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
writeConfigFile(path, {
|
|
84
84
|
providerName: providerNameInput || defaults.providerName,
|
|
85
85
|
baseUrl: baseUrlInput || defaults.baseUrl,
|
|
86
86
|
authRequired,
|
|
87
87
|
authHeader,
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
ctx.ui.notify(`Saved CLIProxyAPI config to ${path}. Reloading pi to apply it...`, "info");
|
|
88
|
+
});
|
|
89
|
+
ctx.ui.notify(`Saved CLIProxyAPI config to ${path}. Reloading pi to apply connection changes...`, "info");
|
|
91
90
|
await ctx.reload();
|
|
92
91
|
}
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
`models.dev cache: ${discovery.sources.modelsDev}, age ${age(modelsDevCachePath())}`,
|
|
106
|
-
].join("\n"), "info");
|
|
107
|
-
} catch (error) {
|
|
108
|
-
ctx.ui.notify(formatStatusFailure(config, error), "error");
|
|
109
|
-
}
|
|
93
|
+
function statusText(config: ReturnType<typeof loadConfig>, snapshot: CatalogSnapshot): string {
|
|
94
|
+
return [
|
|
95
|
+
`CLIProxyAPI provider: ${config.providerName}`,
|
|
96
|
+
`Base URL: ${config.baseUrl}`,
|
|
97
|
+
`Auth required: ${config.authRequired ? "yes" : "no"}`,
|
|
98
|
+
`Models: ${snapshot.built.stats.total} (${snapshot.built.stats.enriched} enriched, ${snapshot.built.stats.unmatched} unmatched)`,
|
|
99
|
+
`Reasoning models: ${capabilityCount(snapshot, "reasoning")}`,
|
|
100
|
+
`Image-capable models: ${capabilityCount(snapshot, "image")}`,
|
|
101
|
+
`CPA snapshot: ${age(snapshot.cpaUpdatedAt)}`,
|
|
102
|
+
`models.dev metadata: ${snapshot.metadataSource}${snapshot.metadataUpdatedAt ? `, ${age(snapshot.metadataUpdatedAt)}` : ""}`,
|
|
103
|
+
].join("\n");
|
|
110
104
|
}
|
|
111
105
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const built = buildProviderModels(discovery.cpaModels, discovery.modelsDevCatalog, config.modelAliases);
|
|
117
|
-
ctx.ui.notify(`Refreshed CLIProxyAPI caches. ${built.stats.total} CPA models, ${built.stats.enriched} enriched, ${built.stats.unmatched} unmatched. Run /reload to load refreshed models.`, "info");
|
|
118
|
-
} catch (error) {
|
|
119
|
-
ctx.ui.notify(`CLIProxyAPI refresh failed: ${errorText(error)}`, "error");
|
|
120
|
-
}
|
|
106
|
+
function refreshPart(label: string, result: SourceRefreshResult): string {
|
|
107
|
+
if (!result.attempted) return `${label}: not requested`;
|
|
108
|
+
if (result.error) return `${label}: failed (${errorText(result.error)}); retained previous snapshot`;
|
|
109
|
+
return `${label}: ${result.changed ? "updated" : "unchanged"}`;
|
|
121
110
|
}
|
|
122
111
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const sample = built.stats.unmatchedModelIds.slice(0, 30);
|
|
129
|
-
const body = sample.length === 0
|
|
130
|
-
? "All CPA models matched models.dev metadata."
|
|
131
|
-
: `Unmatched CPA models (${built.stats.unmatched}):\n${sample.map((id) => ` "${id}": "<models.dev-id>"`).join("\n")}`;
|
|
132
|
-
ctx.ui.notify(body, built.stats.unmatched ? "warning" : "info");
|
|
133
|
-
} catch (error) {
|
|
134
|
-
ctx.ui.notify(`CLIProxyAPI aliases failed: ${errorText(error)}`, "error");
|
|
135
|
-
}
|
|
112
|
+
function parseRefreshTarget(value: string | undefined): RefreshTarget | undefined {
|
|
113
|
+
if (!value || value === "all") return "all";
|
|
114
|
+
if (value === "models") return "models";
|
|
115
|
+
if (value === "metadata") return "metadata";
|
|
116
|
+
return undefined;
|
|
136
117
|
}
|
|
137
118
|
|
|
138
119
|
export function cliproxyapiArgumentCompletions(prefix: string): Array<{ value: string; label: string }> {
|
|
139
|
-
return ["config", "status", "refresh", "aliases", "help"]
|
|
120
|
+
return ["config", "status", "refresh", "refresh models", "refresh metadata", "aliases", "help"]
|
|
140
121
|
.filter((item) => item.startsWith(prefix))
|
|
141
122
|
.map((value) => ({ value, label: value }));
|
|
142
123
|
}
|
|
143
124
|
|
|
144
|
-
export function registerCliproxyapiCommand(pi: ExtensionAPI,
|
|
125
|
+
export function registerCliproxyapiCommand(pi: ExtensionAPI, runtime?: ProviderRuntime, catalog?: ProviderCatalog): void {
|
|
145
126
|
pi.registerCommand("cliproxyapi", {
|
|
146
|
-
description: "Configure and inspect the CLIProxyAPI provider.",
|
|
127
|
+
description: "Configure, refresh, and inspect the CLIProxyAPI provider.",
|
|
147
128
|
getArgumentCompletions(prefix) {
|
|
148
129
|
return cliproxyapiArgumentCompletions(prefix);
|
|
149
130
|
},
|
|
150
131
|
async handler(args, ctx) {
|
|
151
|
-
const subcommand = args.trim().split(/\s
|
|
132
|
+
const [subcommand = "help", option] = args.trim().split(/\s+/);
|
|
152
133
|
if (subcommand === "config") return runConfig(ctx);
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
134
|
+
if (!runtime || !catalog) {
|
|
135
|
+
ctx.ui.notify("CLIProxyAPI provider is unavailable. Run /cliproxyapi config and reload pi.", "error");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const config = loadConfig(ctx.cwd);
|
|
139
|
+
if (subcommand === "status") {
|
|
140
|
+
const snapshot = catalog.current() ?? await catalog.load();
|
|
141
|
+
ctx.ui.notify(statusText(config, snapshot), "info");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (subcommand === "refresh") {
|
|
145
|
+
const target = parseRefreshTarget(option);
|
|
146
|
+
if (!target) {
|
|
147
|
+
ctx.ui.notify("Usage: /cliproxyapi refresh [models|metadata]", "warning");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const result = await runtime.refresh(target, "manual");
|
|
151
|
+
const level = result.models.error || result.metadata.error ? "warning" : "info";
|
|
152
|
+
ctx.ui.notify([
|
|
153
|
+
"CLIProxyAPI provider refresh complete.",
|
|
154
|
+
refreshPart("CPA models", result.models),
|
|
155
|
+
refreshPart("models.dev metadata", result.metadata),
|
|
156
|
+
`Registered: ${result.snapshot.built.stats.total} models, ${result.snapshot.built.stats.enriched} enriched, ${result.snapshot.built.stats.unmatched} unmatched.`,
|
|
157
|
+
].join("\n"), level);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (subcommand === "aliases") {
|
|
161
|
+
const snapshot = catalog.current() ?? await catalog.load();
|
|
162
|
+
const sample = snapshot.built.stats.unmatchedModelIds.slice(0, 30);
|
|
163
|
+
const body = sample.length === 0
|
|
164
|
+
? "All CPA models matched models.dev metadata."
|
|
165
|
+
: `Unmatched CPA models (${snapshot.built.stats.unmatched}):\n${sample.map((id) => ` "${id}": "<models.dev-id>"`).join("\n")}`;
|
|
166
|
+
ctx.ui.notify(body, snapshot.built.stats.unmatched ? "warning" : "info");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
ctx.ui.notify("Usage: /cliproxyapi config|status|refresh [models|metadata]|aliases", "info");
|
|
157
170
|
},
|
|
158
171
|
});
|
|
159
172
|
}
|
package/src/config.ts
CHANGED
|
@@ -11,8 +11,6 @@ export const DEFAULT_CONFIG: CpaProviderConfig = {
|
|
|
11
11
|
authRequired: true,
|
|
12
12
|
authHeader: true,
|
|
13
13
|
headers: {},
|
|
14
|
-
cpaCacheTtlSeconds: 60 * 60,
|
|
15
|
-
modelsDevCacheTtlSeconds: 24 * 60 * 60,
|
|
16
14
|
modelsDevEnabled: true,
|
|
17
15
|
modelAliases: {},
|
|
18
16
|
};
|
|
@@ -127,13 +125,6 @@ function validateConfigLayer(value: unknown, path: string): ConfigLayer {
|
|
|
127
125
|
}
|
|
128
126
|
}
|
|
129
127
|
|
|
130
|
-
const ttlFields = ["cpaCacheTtlSeconds", "modelsDevCacheTtlSeconds"];
|
|
131
|
-
for (const field of ttlFields) {
|
|
132
|
-
if (record[field] !== undefined && (typeof record[field] !== "number" || !Number.isFinite(record[field]) || record[field] <= 0)) {
|
|
133
|
-
throw new Error(`${field} must be a positive number in config file: ${path}`);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
128
|
if (record.headers !== undefined && !isStringMap(record.headers)) {
|
|
138
129
|
throw new Error(`headers must be an object with string values in config file: ${path}`);
|
|
139
130
|
}
|
package/src/cpa.ts
CHANGED
|
@@ -16,13 +16,8 @@ export function modelsEndpoint(baseUrl: string): string {
|
|
|
16
16
|
return `${baseUrl.replace(/\/+$/, "")}/models`;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
if (!response || typeof response !== "object" || !Array.isArray(response.data)) {
|
|
22
|
-
throw new Error("CPA /v1/models response must contain a data array");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return response.data.flatMap((entry) => {
|
|
19
|
+
function parseCpaModelEntries(entries: unknown[]): CpaModel[] {
|
|
20
|
+
const models = entries.flatMap((entry) => {
|
|
26
21
|
if (!entry || typeof entry !== "object") return [];
|
|
27
22
|
const record = entry as Record<string, unknown>;
|
|
28
23
|
if (typeof record.id !== "string" || record.id.trim() === "") return [];
|
|
@@ -33,6 +28,25 @@ export function parseCpaModelsResponse(payload: unknown): CpaModel[] {
|
|
|
33
28
|
created: typeof record.created === "number" ? record.created : undefined,
|
|
34
29
|
}];
|
|
35
30
|
});
|
|
31
|
+
|
|
32
|
+
const unique = new Map<string, CpaModel>();
|
|
33
|
+
for (const model of models) unique.set(model.id, model);
|
|
34
|
+
return [...unique.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseCpaModelsCache(payload: unknown): CpaModel[] {
|
|
38
|
+
if (!Array.isArray(payload)) throw new Error("CPA model snapshot must be an array");
|
|
39
|
+
const models = parseCpaModelEntries(payload);
|
|
40
|
+
if (models.length !== payload.length) throw new Error("CPA model snapshot contains invalid entries");
|
|
41
|
+
return models;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseCpaModelsResponse(payload: unknown): CpaModel[] {
|
|
45
|
+
const response = payload as CpaModelsResponse;
|
|
46
|
+
if (!response || typeof response !== "object" || !Array.isArray(response.data)) {
|
|
47
|
+
throw new Error("CPA /v1/models response must contain a data array");
|
|
48
|
+
}
|
|
49
|
+
return parseCpaModelEntries(response.data);
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
export async function fetchCpaModels(baseUrl: string, headers: Record<string, string> = {}, timeoutMs?: number): Promise<CpaModel[]> {
|