@juvio15/pi-cliproxyapi-provider 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/package.json +51 -4
- package/src/core/catalog.ts +400 -0
- package/src/core/connection.ts +119 -0
- package/src/core/costs.ts +477 -0
- package/src/core/index.ts +189 -0
- package/src/core/oauth.ts +279 -0
- package/src/core/streams.ts +89 -0
- package/src/core/usage.ts +813 -0
- package/src/extension/completions.ts +20 -0
- package/src/extension/config.ts +314 -0
- package/src/extension/fast.ts +81 -0
- package/src/extension/files.ts +146 -0
- package/src/extension/footer.ts +229 -0
- package/src/extension/index.ts +477 -0
- package/src/extension/oauth.ts +203 -0
- package/src/extension/pause.ts +126 -0
- package/src/extension/prices.ts +205 -0
- package/src/extension/prompt-cache.ts +17 -0
- package/src/extension/provider.ts +386 -0
- package/src/extension/retry.ts +41 -0
- package/src/extension/status.ts +166 -0
- package/src/extension/tps.ts +215 -0
- package/src/extension/usage.ts +609 -0
- package/src/extension/web-search.ts +307 -0
- package/src/web-search/endpoint.ts +21 -0
- package/src/web-search/index.ts +21 -0
- package/src/web-search/model-selector.ts +93 -0
- package/src/web-search/search.ts +571 -0
- package/src/web-search/sse.ts +144 -0
- package/README.md +0 -3
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/* oxlint-disable eslint/no-await-in-loop -- OAuth login prompts and validation are intentionally sequential so each retry uses the latest input. */
|
|
2
|
+
import type {
|
|
3
|
+
AuthResult,
|
|
4
|
+
ModelCost,
|
|
5
|
+
OAuthCredential,
|
|
6
|
+
Provider,
|
|
7
|
+
ProviderAuthInteraction,
|
|
8
|
+
RefreshModelsContext,
|
|
9
|
+
} from "@earendil-works/pi-ai";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createCliproxyProvider,
|
|
13
|
+
fetchCatalog,
|
|
14
|
+
mapCatalog,
|
|
15
|
+
matchModelCost,
|
|
16
|
+
mergeCatalog,
|
|
17
|
+
resolveEndpoints,
|
|
18
|
+
} from "../core/index.ts";
|
|
19
|
+
import type {
|
|
20
|
+
CliproxyApi,
|
|
21
|
+
CliproxyModel,
|
|
22
|
+
CostCatalog,
|
|
23
|
+
ResolvedConnection,
|
|
24
|
+
} from "../core/index.ts";
|
|
25
|
+
import {
|
|
26
|
+
buildOAuthCredentials,
|
|
27
|
+
CREDENTIAL_TTL_MS,
|
|
28
|
+
decodeRefreshMeta,
|
|
29
|
+
defaultConnectionBaseUrl,
|
|
30
|
+
loadCliproxyConfig,
|
|
31
|
+
loadModelsCache,
|
|
32
|
+
resolveExtensionConnection,
|
|
33
|
+
saveCliproxyConfig,
|
|
34
|
+
saveModelsCache,
|
|
35
|
+
} from "./config.ts";
|
|
36
|
+
import type { CliproxyConfig } from "./config.ts";
|
|
37
|
+
import type { FastModeController } from "./fast.ts";
|
|
38
|
+
import { PriceCache } from "./prices.ts";
|
|
39
|
+
import { anthropicPromptCache } from "./prompt-cache.ts";
|
|
40
|
+
|
|
41
|
+
export interface ExtensionProviderOptions {
|
|
42
|
+
agentDir: string;
|
|
43
|
+
connection: ResolvedConnection;
|
|
44
|
+
providerId: string;
|
|
45
|
+
providerName: string;
|
|
46
|
+
fast: FastModeController;
|
|
47
|
+
config?: CliproxyConfig;
|
|
48
|
+
/** Used by the default price cache when `prices` is not given. */
|
|
49
|
+
costsUrl?: string;
|
|
50
|
+
fetchFn?: typeof fetch;
|
|
51
|
+
/** Shared with the status command. Defaults to a cache in agentDir that uses costsUrl and fetchFn. */
|
|
52
|
+
prices?: PriceCache;
|
|
53
|
+
/** Called with a ready-to-show message when models.dev prices could not be loaded. */
|
|
54
|
+
onPriceWarning?: (message: string) => void;
|
|
55
|
+
onModels?: (models: readonly CliproxyModel[]) => void;
|
|
56
|
+
/** Drops models that are out of quota from pi's available list. getModels keeps them so sessions can still resolve them. */
|
|
57
|
+
filterAvailable?: <T extends { id: string }>(models: readonly T[]) => T[];
|
|
58
|
+
/** False for the empty provider registered solely to expose /login. */
|
|
59
|
+
configured?: boolean;
|
|
60
|
+
/** Clock for the catalog freshness check. Tests inject it. */
|
|
61
|
+
now?: () => number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ExtensionProviderController {
|
|
65
|
+
provider: Provider<CliproxyApi>;
|
|
66
|
+
getModels: () => readonly CliproxyModel[];
|
|
67
|
+
getStandardCost: (modelId: string) => ModelCost | undefined;
|
|
68
|
+
/** When the catalog was last fetched, or 0 when it never was. */
|
|
69
|
+
getCatalogFetchedAt: () => number;
|
|
70
|
+
refresh: (
|
|
71
|
+
connection?: ResolvedConnection
|
|
72
|
+
) => Promise<readonly CliproxyModel[]>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A non-forced refresh skips the network while the catalog is younger than this. */
|
|
76
|
+
const CATALOG_TTL_MS = 10 * 60_000;
|
|
77
|
+
|
|
78
|
+
interface RemoteCatalog {
|
|
79
|
+
costs: CostCatalog;
|
|
80
|
+
fastModelIds: string[];
|
|
81
|
+
models: CliproxyModel[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const oauthConnection = (
|
|
85
|
+
credential: OAuthCredential,
|
|
86
|
+
fallback: ResolvedConnection
|
|
87
|
+
): ResolvedConnection => {
|
|
88
|
+
const stored = decodeRefreshMeta(credential.refresh);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
...resolveEndpoints(stored.baseUrl ?? fallback.baseUrl),
|
|
92
|
+
apiKey: credential.access,
|
|
93
|
+
baseUrl: stored.baseUrl ?? fallback.baseUrl,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const createExtensionProvider = (
|
|
98
|
+
options: ExtensionProviderOptions
|
|
99
|
+
): ExtensionProviderController => {
|
|
100
|
+
const config = options.config ?? loadCliproxyConfig(options.agentDir);
|
|
101
|
+
|
|
102
|
+
const cached = loadModelsCache(
|
|
103
|
+
options.agentDir,
|
|
104
|
+
options.connection.baseUrl,
|
|
105
|
+
options.fast.isEnabled()
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// The models cache predates promptCache, so cached Claude models get it here.
|
|
109
|
+
let currentModels: CliproxyModel[] = (cached?.models ?? []).map((model) => {
|
|
110
|
+
const loaded: CliproxyModel = {
|
|
111
|
+
...model,
|
|
112
|
+
thoughtSummary: config.thinkingDisplay?.[model.id] ?? "omitted",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const promptCache =
|
|
116
|
+
model.api === "anthropic-messages"
|
|
117
|
+
? anthropicPromptCache(model.id)
|
|
118
|
+
: undefined;
|
|
119
|
+
|
|
120
|
+
if (promptCache) {
|
|
121
|
+
loaded.promptCache = promptCache;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return loaded;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
let currentCosts: CostCatalog | undefined;
|
|
128
|
+
const now = options.now ?? Date.now;
|
|
129
|
+
let catalogFetchedAt = cached?.fetchedAt ?? 0;
|
|
130
|
+
|
|
131
|
+
const prices =
|
|
132
|
+
options.prices ??
|
|
133
|
+
new PriceCache({
|
|
134
|
+
agentDir: options.agentDir,
|
|
135
|
+
costsUrl: options.costsUrl,
|
|
136
|
+
fetchFn: options.fetchFn,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (cached) {
|
|
140
|
+
options.fast.setSupportedModelIds(cached.fastModelIds);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const core = createCliproxyProvider({
|
|
144
|
+
apiKey: options.connection.apiKey,
|
|
145
|
+
baseUrl: options.connection.baseUrl,
|
|
146
|
+
costsUrl: options.costsUrl,
|
|
147
|
+
fast: options.fast.isEnabled,
|
|
148
|
+
fetch: options.fetchFn,
|
|
149
|
+
models: currentModels,
|
|
150
|
+
promptCache: anthropicPromptCache,
|
|
151
|
+
providerId: options.providerId,
|
|
152
|
+
providerName: options.providerName,
|
|
153
|
+
thoughtSummaries: config.thinkingDisplay,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const fetchRemote = async (
|
|
157
|
+
connection: ResolvedConnection,
|
|
158
|
+
signal?: AbortSignal
|
|
159
|
+
): Promise<RemoteCatalog> => {
|
|
160
|
+
const entries = await fetchCatalog({
|
|
161
|
+
apiKey: connection.apiKey,
|
|
162
|
+
baseUrl: connection.baseUrl,
|
|
163
|
+
fetch: options.fetchFn,
|
|
164
|
+
signal,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Prices are kept per catalog id, so the ids come from the same mapping the models do.
|
|
168
|
+
const ids = mapCatalog(entries, {
|
|
169
|
+
baseUrl: connection.baseUrl,
|
|
170
|
+
promptCache: anthropicPromptCache,
|
|
171
|
+
}).models.map((model) => model.id);
|
|
172
|
+
|
|
173
|
+
const { catalog: costs, warning } = await prices.load(ids, signal);
|
|
174
|
+
|
|
175
|
+
if (warning) {
|
|
176
|
+
options.onPriceWarning?.(warning);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const mapped = mapCatalog(entries, {
|
|
180
|
+
baseUrl: connection.baseUrl,
|
|
181
|
+
costs,
|
|
182
|
+
fast: options.fast.isEnabled,
|
|
183
|
+
promptCache: anthropicPromptCache,
|
|
184
|
+
providerId: options.providerId,
|
|
185
|
+
thoughtSummaries: config.thinkingDisplay,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
costs,
|
|
190
|
+
fastModelIds: mapped.fastModelIds,
|
|
191
|
+
models: mergeCatalog(currentModels, mapped.models),
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const commit = (
|
|
196
|
+
connection: ResolvedConnection,
|
|
197
|
+
remote: RemoteCatalog
|
|
198
|
+
): readonly CliproxyModel[] => {
|
|
199
|
+
catalogFetchedAt = now();
|
|
200
|
+
currentModels = remote.models;
|
|
201
|
+
currentCosts = remote.costs;
|
|
202
|
+
options.fast.setSupportedModelIds(remote.fastModelIds);
|
|
203
|
+
saveModelsCache(options.agentDir, {
|
|
204
|
+
baseUrl: connection.baseUrl,
|
|
205
|
+
fast: options.fast.isEnabled(),
|
|
206
|
+
fastModelIds: remote.fastModelIds,
|
|
207
|
+
fetchedAt: catalogFetchedAt,
|
|
208
|
+
models: currentModels,
|
|
209
|
+
});
|
|
210
|
+
options.onModels?.(currentModels);
|
|
211
|
+
|
|
212
|
+
return currentModels;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const refresh = async (
|
|
216
|
+
connection = resolveExtensionConnection({
|
|
217
|
+
agentDir: options.agentDir,
|
|
218
|
+
env: process.env,
|
|
219
|
+
providerId: options.providerId,
|
|
220
|
+
}) ?? options.connection
|
|
221
|
+
): Promise<readonly CliproxyModel[]> =>
|
|
222
|
+
commit(connection, await fetchRemote(connection));
|
|
223
|
+
|
|
224
|
+
const refreshModels = async (
|
|
225
|
+
context: RefreshModelsContext
|
|
226
|
+
): Promise<void> => {
|
|
227
|
+
if (!context.allowNetwork) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!context.force && now() - catalogFetchedAt < CATALOG_TTL_MS) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let { connection } = options;
|
|
236
|
+
|
|
237
|
+
if (context.credential?.type === "oauth") {
|
|
238
|
+
connection = oauthConnection(context.credential, options.connection);
|
|
239
|
+
} else if (context.credential?.type === "api_key") {
|
|
240
|
+
connection = {
|
|
241
|
+
...options.connection,
|
|
242
|
+
apiKey: context.credential.key ?? options.connection.apiKey,
|
|
243
|
+
};
|
|
244
|
+
} else {
|
|
245
|
+
connection =
|
|
246
|
+
resolveExtensionConnection({
|
|
247
|
+
agentDir: options.agentDir,
|
|
248
|
+
env: process.env,
|
|
249
|
+
providerId: options.providerId,
|
|
250
|
+
}) ?? options.connection;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const remote = await fetchRemote(connection, context.signal);
|
|
254
|
+
await context.publish({
|
|
255
|
+
update: () => {
|
|
256
|
+
commit(connection, remote);
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const login = async (
|
|
262
|
+
interaction: ProviderAuthInteraction
|
|
263
|
+
): Promise<OAuthCredential> => {
|
|
264
|
+
let baseUrl = defaultConnectionBaseUrl(
|
|
265
|
+
options.agentDir,
|
|
266
|
+
options.providerId
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
while (true) {
|
|
270
|
+
interaction.notify({
|
|
271
|
+
message:
|
|
272
|
+
"Configure CLIProxyAPI with its root or /v1 base URL and API key.",
|
|
273
|
+
type: "progress",
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const enteredBaseUrl = await interaction.prompt({
|
|
277
|
+
message: `CLIProxyAPI base URL [${baseUrl}]:`,
|
|
278
|
+
placeholder: baseUrl,
|
|
279
|
+
type: "text",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
baseUrl = enteredBaseUrl.trim() || baseUrl;
|
|
283
|
+
resolveEndpoints(baseUrl);
|
|
284
|
+
|
|
285
|
+
const enteredApiKey = await interaction.prompt({
|
|
286
|
+
message: "CLIProxyAPI API key:",
|
|
287
|
+
placeholder: "sk-...",
|
|
288
|
+
type: "secret",
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const apiKey = enteredApiKey.trim();
|
|
292
|
+
|
|
293
|
+
if (!apiKey) {
|
|
294
|
+
throw new Error("API key cannot be empty.");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const connection = {
|
|
298
|
+
...resolveEndpoints(baseUrl),
|
|
299
|
+
apiKey,
|
|
300
|
+
baseUrl,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
interaction.notify({
|
|
305
|
+
message: "Validating credentials via the models endpoint...",
|
|
306
|
+
type: "progress",
|
|
307
|
+
});
|
|
308
|
+
await refresh(connection);
|
|
309
|
+
saveCliproxyConfig(options.agentDir, {
|
|
310
|
+
apiKey,
|
|
311
|
+
baseUrl,
|
|
312
|
+
providerId: options.providerId,
|
|
313
|
+
providerName: options.providerName,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return { ...buildOAuthCredentials(baseUrl, apiKey), type: "oauth" };
|
|
317
|
+
} catch (error) {
|
|
318
|
+
interaction.signal.throwIfAborted();
|
|
319
|
+
interaction.notify({
|
|
320
|
+
message: `Login validation failed: ${error instanceof Error ? error.message : String(error)}. Please re-enter the connection.`,
|
|
321
|
+
type: "progress",
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const provider: Provider<CliproxyApi> = {
|
|
328
|
+
...core,
|
|
329
|
+
auth: {
|
|
330
|
+
apiKey: {
|
|
331
|
+
name: "CLIProxyAPI environment or configuration",
|
|
332
|
+
resolve: ({ credential }) => {
|
|
333
|
+
const connection = credential?.key
|
|
334
|
+
? { ...options.connection, apiKey: credential.key }
|
|
335
|
+
: (resolveExtensionConnection({
|
|
336
|
+
agentDir: options.agentDir,
|
|
337
|
+
env: process.env,
|
|
338
|
+
providerId: options.providerId,
|
|
339
|
+
}) ?? options.connection);
|
|
340
|
+
|
|
341
|
+
if (!connection || options.configured === false) {
|
|
342
|
+
const unconfigured: AuthResult | undefined = undefined;
|
|
343
|
+
|
|
344
|
+
return Promise.resolve(unconfigured);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return Promise.resolve({
|
|
348
|
+
auth: { apiKey: connection.apiKey },
|
|
349
|
+
source: "CLIPROXYAPI_API_KEY or cliproxyapi.json",
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
oauth: {
|
|
354
|
+
login,
|
|
355
|
+
name: options.providerName,
|
|
356
|
+
refresh: (credential) =>
|
|
357
|
+
Promise.resolve({
|
|
358
|
+
...credential,
|
|
359
|
+
expires: Date.now() + CREDENTIAL_TTL_MS,
|
|
360
|
+
}),
|
|
361
|
+
toAuth: (credential) => Promise.resolve({ apiKey: credential.access }),
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
filterModels: (models) => options.filterAvailable?.(models) ?? models,
|
|
365
|
+
getModels: () => currentModels,
|
|
366
|
+
refreshModels,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
getCatalogFetchedAt: () => catalogFetchedAt,
|
|
371
|
+
getModels: () => currentModels,
|
|
372
|
+
getStandardCost: (modelId) => {
|
|
373
|
+
const costs = currentCosts ?? prices.cached();
|
|
374
|
+
|
|
375
|
+
if (costs) {
|
|
376
|
+
return matchModelCost(modelId, costs);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return options.fast.isEnabled()
|
|
380
|
+
? undefined
|
|
381
|
+
: currentModels.find((model) => model.id === modelId)?.cost;
|
|
382
|
+
},
|
|
383
|
+
provider,
|
|
384
|
+
refresh,
|
|
385
|
+
};
|
|
386
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { isRetryableAssistantError } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
const TRANSIENT_STREAM_ERROR =
|
|
6
|
+
/\bclosed network connection\b|\bstream disconnected before completion: stream closed before response\.completed\b|\binvalid SSE data JSON\b/iu;
|
|
7
|
+
|
|
8
|
+
export const normalizeTransientNetworkError = (
|
|
9
|
+
message: AssistantMessage
|
|
10
|
+
): AssistantMessage => {
|
|
11
|
+
if (
|
|
12
|
+
message.stopReason !== "error" ||
|
|
13
|
+
!message.errorMessage ||
|
|
14
|
+
isRetryableAssistantError(message) ||
|
|
15
|
+
!TRANSIENT_STREAM_ERROR.test(message.errorMessage)
|
|
16
|
+
) {
|
|
17
|
+
return message;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
...message,
|
|
22
|
+
errorMessage: `network error: ${message.errorMessage}`,
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const registerTransientNetworkErrorRetry = (
|
|
27
|
+
pi: ExtensionAPI,
|
|
28
|
+
providerId: string
|
|
29
|
+
): void => {
|
|
30
|
+
pi.on("message_end", (event) => {
|
|
31
|
+
const { message } = event;
|
|
32
|
+
|
|
33
|
+
if (message.role !== "assistant" || message.provider !== providerId) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const normalized = normalizeTransientNetworkError(message);
|
|
38
|
+
|
|
39
|
+
return normalized === message ? undefined : { message: normalized };
|
|
40
|
+
});
|
|
41
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { ThoughtSummary, ValueSource } from "../core/index.ts";
|
|
4
|
+
import { MANAGEMENT_KEY_ENV } from "./usage.ts";
|
|
5
|
+
import type { ManagementKeySource } from "./usage.ts";
|
|
6
|
+
|
|
7
|
+
/** Everything /cliproxyapi-status shows. It holds where keys come from, never the keys. */
|
|
8
|
+
export interface StatusSnapshot {
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
/** Undefined when no API key is configured. */
|
|
11
|
+
keySource?: ValueSource;
|
|
12
|
+
models: number;
|
|
13
|
+
stale: number;
|
|
14
|
+
/** Undefined when the catalog was never fetched. */
|
|
15
|
+
catalogAgeMs?: number;
|
|
16
|
+
/** Undefined before prices were first loaded. */
|
|
17
|
+
prices?: { ageMs: number; etag?: string };
|
|
18
|
+
fast: boolean;
|
|
19
|
+
fastModels: number;
|
|
20
|
+
paused: boolean;
|
|
21
|
+
/** Undefined without a management key. */
|
|
22
|
+
management?: { source: ManagementKeySource; usageAgeMs?: number };
|
|
23
|
+
searchModel: { configured?: string; effective?: string };
|
|
24
|
+
thinking: Readonly<Record<string, ThoughtSummary>>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MINUTE_MS = 60_000;
|
|
28
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
29
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
30
|
+
const ETAG_PREFIX_LENGTH = 8;
|
|
31
|
+
const ETAG_WRAPPING = /^(?:W\/)?"?|"$/gu;
|
|
32
|
+
|
|
33
|
+
const KEY_SOURCE_LABELS: Readonly<Record<ValueSource, string>> = {
|
|
34
|
+
"cliproxyapi.json": "key from cliproxyapi.json",
|
|
35
|
+
default: "not configured",
|
|
36
|
+
env: "key from env CLIPROXYAPI_API_KEY",
|
|
37
|
+
login: "key from auth.json (/login)",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const MANAGEMENT_SOURCE_LABELS: Readonly<Record<ManagementKeySource, string>> =
|
|
41
|
+
{
|
|
42
|
+
"cliproxyapi.json": "key set (cliproxyapi.json)",
|
|
43
|
+
env: `key set (env ${MANAGEMENT_KEY_ENV})`,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const THINKING_ORDER: readonly ThoughtSummary[] = ["summarized", "omitted"];
|
|
47
|
+
|
|
48
|
+
/** "just now", "4 min old", "3 h old", "2 d old". */
|
|
49
|
+
const formatShortAge = (ageMs: number): string => {
|
|
50
|
+
if (ageMs < MINUTE_MS) {
|
|
51
|
+
return "just now";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (ageMs < HOUR_MS) {
|
|
55
|
+
return `${Math.floor(ageMs / MINUTE_MS)} min old`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (ageMs < 2 * DAY_MS) {
|
|
59
|
+
return `${Math.floor(ageMs / HOUR_MS)} h old`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return `${Math.floor(ageMs / DAY_MS)} d old`;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const formatModels = (snapshot: StatusSnapshot): string => {
|
|
66
|
+
const stale = snapshot.stale > 0 ? ` (${snapshot.stale} stale)` : "";
|
|
67
|
+
|
|
68
|
+
const catalog =
|
|
69
|
+
snapshot.catalogAgeMs === undefined
|
|
70
|
+
? "not fetched"
|
|
71
|
+
: formatShortAge(snapshot.catalogAgeMs);
|
|
72
|
+
|
|
73
|
+
return `${snapshot.models}${stale}, catalog ${catalog}`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const formatPrices = (prices: StatusSnapshot["prices"]): string => {
|
|
77
|
+
if (!prices) {
|
|
78
|
+
return "not loaded";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const etag = prices.etag
|
|
82
|
+
?.replaceAll(ETAG_WRAPPING, "")
|
|
83
|
+
.slice(0, ETAG_PREFIX_LENGTH);
|
|
84
|
+
|
|
85
|
+
return `models.dev, ${formatShortAge(prices.ageMs)}${etag ? ` (etag ${etag})` : ""}`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const formatFast = (snapshot: StatusSnapshot): string => {
|
|
89
|
+
const support =
|
|
90
|
+
snapshot.fastModels === 1
|
|
91
|
+
? "1 model supports it"
|
|
92
|
+
: `${snapshot.fastModels} models support it`;
|
|
93
|
+
|
|
94
|
+
return `${snapshot.fast ? "on" : "off"} (${support})`;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const formatManagement = (management: StatusSnapshot["management"]): string => {
|
|
98
|
+
if (!management) {
|
|
99
|
+
return "not set";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const usage =
|
|
103
|
+
management.usageAgeMs === undefined
|
|
104
|
+
? ""
|
|
105
|
+
: `, usage ${formatShortAge(management.usageAgeMs)}`;
|
|
106
|
+
|
|
107
|
+
return `${MANAGEMENT_SOURCE_LABELS[management.source]}${usage}`;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const formatSearchModel = (
|
|
111
|
+
searchModel: StatusSnapshot["searchModel"]
|
|
112
|
+
): string =>
|
|
113
|
+
searchModel.configured ??
|
|
114
|
+
`auto (${searchModel.effective ?? "none available"})`;
|
|
115
|
+
|
|
116
|
+
const formatThinking = (thinking: StatusSnapshot["thinking"]): string => {
|
|
117
|
+
const groups = THINKING_ORDER.flatMap((value) => {
|
|
118
|
+
const ids = Object.keys(thinking)
|
|
119
|
+
.filter((id) => thinking[id] === value)
|
|
120
|
+
.toSorted();
|
|
121
|
+
|
|
122
|
+
return ids.length > 0 ? [`${value}: ${ids.join(", ")}`] : [];
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return groups.length > 0 ? groups.join("; ") : "omitted for all models";
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** The seven-line block /cliproxyapi-status shows. */
|
|
129
|
+
export const formatStatus = (snapshot: StatusSnapshot): string =>
|
|
130
|
+
[
|
|
131
|
+
`CLIProxyAPI ${snapshot.baseUrl} ${snapshot.keySource ? KEY_SOURCE_LABELS[snapshot.keySource] : "not configured"}`,
|
|
132
|
+
`Models ${formatModels(snapshot)}`,
|
|
133
|
+
`Prices ${formatPrices(snapshot.prices)}`,
|
|
134
|
+
`Fast ${formatFast(snapshot)} Paused ${snapshot.paused ? "yes" : "no"}`,
|
|
135
|
+
`Management ${formatManagement(snapshot.management)}`,
|
|
136
|
+
`Web search ${formatSearchModel(snapshot.searchModel)}`,
|
|
137
|
+
`Thinking ${formatThinking(snapshot.thinking)}`,
|
|
138
|
+
].join("\n");
|
|
139
|
+
|
|
140
|
+
export const registerStatus = (
|
|
141
|
+
pi: ExtensionAPI,
|
|
142
|
+
gather: () => StatusSnapshot
|
|
143
|
+
): void => {
|
|
144
|
+
pi.registerCommand("cliproxyapi-status", {
|
|
145
|
+
description:
|
|
146
|
+
"Show the CLIProxyAPI connection, models, prices, Fast, pause, usage, web search model, and thought summaries.",
|
|
147
|
+
handler: (args, ctx) => {
|
|
148
|
+
if (args.trim()) {
|
|
149
|
+
ctx.ui.notify("Usage: /cliproxyapi-status", "error");
|
|
150
|
+
|
|
151
|
+
return Promise.resolve();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
ctx.ui.notify(formatStatus(gather()), "info");
|
|
156
|
+
} catch (error) {
|
|
157
|
+
ctx.ui.notify(
|
|
158
|
+
`Failed to read CLIProxyAPI status: ${error instanceof Error ? error.message : String(error)}`,
|
|
159
|
+
"error"
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return Promise.resolve();
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
};
|