@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.
@@ -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
+ };