@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,229 @@
|
|
|
1
|
+
import { FooterComponent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import type { Static } from "typebox";
|
|
8
|
+
import { Value } from "typebox/value";
|
|
9
|
+
|
|
10
|
+
import type { FastModeController } from "./fast.ts";
|
|
11
|
+
import type { PauseController } from "./pause.ts";
|
|
12
|
+
|
|
13
|
+
const PATCH = Symbol.for("cliproxyapi-provider/fast-footer");
|
|
14
|
+
const REFRESH_KEY = "cliproxyapi-fast-refresh";
|
|
15
|
+
const ORANGE_TRUECOLOR = "\u001B[38;2;255;165;0m";
|
|
16
|
+
const ORANGE_256 = "\u001B[38;5;214m";
|
|
17
|
+
const FG_RESET = "\u001B[39m";
|
|
18
|
+
|
|
19
|
+
/** The part of pi's private footer session this patch reads. Checked on every render, because pi may change it. */
|
|
20
|
+
const FooterSessionSchema = Type.Object({
|
|
21
|
+
state: Type.Object({
|
|
22
|
+
model: Type.Optional(
|
|
23
|
+
Type.Object({
|
|
24
|
+
id: Type.String(),
|
|
25
|
+
provider: Type.String(),
|
|
26
|
+
reasoning: Type.Boolean(),
|
|
27
|
+
})
|
|
28
|
+
),
|
|
29
|
+
thinkingLevel: Type.Optional(Type.String()),
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
type MutableModel = NonNullable<
|
|
34
|
+
Static<typeof FooterSessionSchema>["state"]["model"]
|
|
35
|
+
>;
|
|
36
|
+
|
|
37
|
+
interface PatchState {
|
|
38
|
+
controllers: Set<FastFooterController>;
|
|
39
|
+
original: FooterComponent["render"];
|
|
40
|
+
patched: FooterComponent["render"];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type PatchableFooter = FooterComponent & { [PATCH]?: PatchState };
|
|
44
|
+
|
|
45
|
+
export const formatFastModelStatus = (
|
|
46
|
+
modelName: string,
|
|
47
|
+
reasoning: boolean,
|
|
48
|
+
thinkingLevel: string,
|
|
49
|
+
fast: boolean,
|
|
50
|
+
fastLabel = "fast",
|
|
51
|
+
paused = false,
|
|
52
|
+
pausedLabel = "paused"
|
|
53
|
+
): string => {
|
|
54
|
+
let status = modelName;
|
|
55
|
+
|
|
56
|
+
if (reasoning) {
|
|
57
|
+
status +=
|
|
58
|
+
thinkingLevel === "off" ? " • thinking off" : ` • ${thinkingLevel}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (fast) {
|
|
62
|
+
status += ` • ${fastLabel}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (paused) {
|
|
66
|
+
status += ` • ${pausedLabel}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return status;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const install = (controller: FastFooterController): void => {
|
|
73
|
+
// SAFETY: FooterComponent is the runtime class whose prototype carries this extension's symbol-owned patch metadata.
|
|
74
|
+
const prototype = FooterComponent.prototype as PatchableFooter;
|
|
75
|
+
let state = prototype[PATCH];
|
|
76
|
+
|
|
77
|
+
if (!state) {
|
|
78
|
+
const original = prototype.render;
|
|
79
|
+
state = {
|
|
80
|
+
controllers: new Set(),
|
|
81
|
+
original,
|
|
82
|
+
patched(this: FooterComponent, width: number): string[] {
|
|
83
|
+
const session: unknown = this["session"];
|
|
84
|
+
|
|
85
|
+
if (!Value.Check(FooterSessionSchema, session)) {
|
|
86
|
+
return original.call(this, width);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { model } = session.state;
|
|
90
|
+
|
|
91
|
+
const active = model
|
|
92
|
+
? [...(state?.controllers ?? [])].find((candidate) =>
|
|
93
|
+
candidate.isProviderModel(model)
|
|
94
|
+
)
|
|
95
|
+
: undefined;
|
|
96
|
+
|
|
97
|
+
if (!model || !active) {
|
|
98
|
+
return original.call(this, width);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { id } = model;
|
|
102
|
+
const { reasoning } = model;
|
|
103
|
+
const fast = active.isEffectiveFor(model);
|
|
104
|
+
|
|
105
|
+
if (fast || active.isPaused()) {
|
|
106
|
+
model.id = active.formatModelStatus(
|
|
107
|
+
id,
|
|
108
|
+
reasoning,
|
|
109
|
+
session.state.thinkingLevel ?? "off",
|
|
110
|
+
fast
|
|
111
|
+
);
|
|
112
|
+
model.reasoning = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
return original.call(this, width);
|
|
117
|
+
} finally {
|
|
118
|
+
model.id = id;
|
|
119
|
+
model.reasoning = reasoning;
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
prototype[PATCH] = state;
|
|
124
|
+
prototype.render = state.patched;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
state.controllers.add(controller);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const uninstall = (controller: FastFooterController): void => {
|
|
131
|
+
// SAFETY: This is the same symbol-owned footer prototype augmented by install().
|
|
132
|
+
const prototype = FooterComponent.prototype as PatchableFooter;
|
|
133
|
+
const state = prototype[PATCH];
|
|
134
|
+
state?.controllers.delete(controller);
|
|
135
|
+
|
|
136
|
+
if (!state || state.controllers.size > 0) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (prototype.render === state.patched) {
|
|
141
|
+
prototype.render = state.original;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
Reflect.deleteProperty(prototype, PATCH);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export class FastFooterController {
|
|
148
|
+
private activeContext: ExtensionContext | undefined;
|
|
149
|
+
private installed = false;
|
|
150
|
+
private readonly providerId: string;
|
|
151
|
+
private readonly fast: FastModeController;
|
|
152
|
+
private readonly pause: PauseController;
|
|
153
|
+
|
|
154
|
+
constructor(
|
|
155
|
+
providerId: string,
|
|
156
|
+
fast: FastModeController,
|
|
157
|
+
pause: PauseController
|
|
158
|
+
) {
|
|
159
|
+
this.providerId = providerId;
|
|
160
|
+
this.fast = fast;
|
|
161
|
+
this.pause = pause;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
register(pi: ExtensionAPI): void {
|
|
165
|
+
pi.on("session_start", (_event, ctx) => {
|
|
166
|
+
if (ctx.mode !== "tui") {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.activeContext = ctx;
|
|
171
|
+
|
|
172
|
+
if (!this.installed) {
|
|
173
|
+
install(this);
|
|
174
|
+
this.installed = true;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
pi.on("session_shutdown", () => {
|
|
178
|
+
this.activeContext = undefined;
|
|
179
|
+
|
|
180
|
+
if (this.installed) {
|
|
181
|
+
uninstall(this);
|
|
182
|
+
this.installed = false;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
isProviderModel(model: MutableModel): boolean {
|
|
188
|
+
return model.provider === this.providerId;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
isEffectiveFor(model: MutableModel): boolean {
|
|
192
|
+
return this.isProviderModel(model) && this.fast.isEffectiveFor(model.id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
isPaused(): boolean {
|
|
196
|
+
return this.pause.isEnabled();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
formatModelStatus(
|
|
200
|
+
modelName: string,
|
|
201
|
+
reasoning: boolean,
|
|
202
|
+
thinkingLevel: string,
|
|
203
|
+
fast: boolean
|
|
204
|
+
): string {
|
|
205
|
+
const theme = this.activeContext?.ui.theme;
|
|
206
|
+
|
|
207
|
+
const color =
|
|
208
|
+
theme?.getColorMode?.() === "truecolor" ? ORANGE_TRUECOLOR : ORANGE_256;
|
|
209
|
+
|
|
210
|
+
const pausedLabel = theme ? `${color}paused${FG_RESET}` : "paused";
|
|
211
|
+
|
|
212
|
+
return formatFastModelStatus(
|
|
213
|
+
modelName,
|
|
214
|
+
reasoning,
|
|
215
|
+
thinkingLevel,
|
|
216
|
+
fast,
|
|
217
|
+
theme?.fg("warning", "fast") ?? "fast",
|
|
218
|
+
this.pause.isEnabled(),
|
|
219
|
+
pausedLabel
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
refresh(ctx: ExtensionContext): void {
|
|
224
|
+
if (ctx.mode === "tui") {
|
|
225
|
+
this.activeContext = ctx;
|
|
226
|
+
ctx.ui.setStatus(REFRESH_KEY, undefined);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import { resolveConnectionSources, resolveEndpoints } from "../core/index.ts";
|
|
8
|
+
import type { CliproxyModel } from "../core/index.ts";
|
|
9
|
+
import { completeFrom } from "./completions.ts";
|
|
10
|
+
import {
|
|
11
|
+
defaultConnectionBaseUrl,
|
|
12
|
+
isModelReferencedAsDefault,
|
|
13
|
+
loadCliproxyConfig,
|
|
14
|
+
loadStoredConnection,
|
|
15
|
+
resolveExtensionConnection,
|
|
16
|
+
resolveIdentity,
|
|
17
|
+
} from "./config.ts";
|
|
18
|
+
import { FastModeController, toggleFastMode } from "./fast.ts";
|
|
19
|
+
import { FastFooterController } from "./footer.ts";
|
|
20
|
+
import { registerOAuthLogin } from "./oauth.ts";
|
|
21
|
+
import { PauseController, registerPause } from "./pause.ts";
|
|
22
|
+
import { PriceCache } from "./prices.ts";
|
|
23
|
+
import { createExtensionProvider } from "./provider.ts";
|
|
24
|
+
import type { ExtensionProviderController } from "./provider.ts";
|
|
25
|
+
import { registerTransientNetworkErrorRetry } from "./retry.ts";
|
|
26
|
+
import { registerStatus } from "./status.ts";
|
|
27
|
+
import type { StatusSnapshot } from "./status.ts";
|
|
28
|
+
import { registerTpsFooter } from "./tps.ts";
|
|
29
|
+
import { MANAGEMENT_KEY_ENV, registerUsage, UsageTracker } from "./usage.ts";
|
|
30
|
+
import type { UsageHandle } from "./usage.ts";
|
|
31
|
+
import { pickSearchModel, registerWebSearch } from "./web-search.ts";
|
|
32
|
+
|
|
33
|
+
const refreshRegistry = async (
|
|
34
|
+
ctx: ExtensionContext,
|
|
35
|
+
providerId: string
|
|
36
|
+
): Promise<void> => {
|
|
37
|
+
const result = await ctx.modelRegistry.refresh({
|
|
38
|
+
force: true,
|
|
39
|
+
providers: [providerId],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const error = result.errors.get(providerId);
|
|
43
|
+
|
|
44
|
+
if (error) {
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const registerCatalogCommands = (
|
|
50
|
+
pi: ExtensionAPI,
|
|
51
|
+
agentDir: string,
|
|
52
|
+
providerId: string,
|
|
53
|
+
fast: FastModeController,
|
|
54
|
+
controller: ExtensionProviderController,
|
|
55
|
+
footer: FastFooterController,
|
|
56
|
+
usage: UsageHandle
|
|
57
|
+
): void => {
|
|
58
|
+
let fastChangePending = false;
|
|
59
|
+
pi.registerCommand("fast", {
|
|
60
|
+
description: "Toggle CLIProxyAPI Fast mode globally.",
|
|
61
|
+
handler: async (args, ctx) => {
|
|
62
|
+
if (args.trim()) {
|
|
63
|
+
ctx.ui.notify("Usage: /fast", "error");
|
|
64
|
+
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (fastChangePending) {
|
|
69
|
+
ctx.ui.notify("Fast mode refresh is already running.", "warning");
|
|
70
|
+
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fastChangePending = true;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const enabled = await toggleFastMode({
|
|
78
|
+
agentDir,
|
|
79
|
+
fast,
|
|
80
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
81
|
+
refresh: () => refreshRegistry(ctx, providerId),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (enabled === undefined) {
|
|
85
|
+
footer.refresh(ctx);
|
|
86
|
+
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const current = ctx.model;
|
|
91
|
+
|
|
92
|
+
if (current?.provider === providerId) {
|
|
93
|
+
const refreshed = ctx.modelRegistry
|
|
94
|
+
.getAll()
|
|
95
|
+
.find(
|
|
96
|
+
(model) =>
|
|
97
|
+
model.provider === providerId && model.id === current.id
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
refreshed &&
|
|
102
|
+
JSON.stringify(refreshed.cost) !== JSON.stringify(current.cost) &&
|
|
103
|
+
!(await pi.setModel(refreshed))
|
|
104
|
+
) {
|
|
105
|
+
throw new Error(`Unable to activate refreshed model ${current.id}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (enabled && !fast.isModelSupported(current.id)) {
|
|
109
|
+
ctx.ui.notify(
|
|
110
|
+
"Fast mode is enabled, but the current model does not support it.",
|
|
111
|
+
"warning"
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
footer.refresh(ctx);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
ctx.ui.notify(
|
|
119
|
+
`Failed to update Fast mode: ${error instanceof Error ? error.message : String(error)}`,
|
|
120
|
+
"error"
|
|
121
|
+
);
|
|
122
|
+
} finally {
|
|
123
|
+
fastChangePending = false;
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
pi.registerCommand("cliproxyapi-refresh", {
|
|
129
|
+
description: "Force refresh CLIProxyAPI models from the remote catalog.",
|
|
130
|
+
handler: async (args, ctx) => {
|
|
131
|
+
if (args.trim()) {
|
|
132
|
+
ctx.ui.notify("Usage: /cliproxyapi-refresh", "error");
|
|
133
|
+
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await refreshRegistry(ctx, providerId);
|
|
139
|
+
await usage.refresh(ctx);
|
|
140
|
+
const models = controller.getModels();
|
|
141
|
+
const stale = models.filter((model) => model.stale).length;
|
|
142
|
+
ctx.ui.notify(
|
|
143
|
+
`Refreshed ${models.length} CLIProxyAPI models${stale ? ` (${stale} retained from cache)` : ""}.`,
|
|
144
|
+
"info"
|
|
145
|
+
);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
ctx.ui.notify(
|
|
148
|
+
`Failed to refresh CLIProxyAPI models: ${error instanceof Error ? error.message : String(error)}`,
|
|
149
|
+
"error"
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const OFFLINE_FLAG = /^(?:1|true|yes)$/iu;
|
|
157
|
+
|
|
158
|
+
export default async function cliproxyapiProvider(
|
|
159
|
+
pi: ExtensionAPI
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
const agentDir = getAgentDir();
|
|
162
|
+
const config = loadCliproxyConfig(agentDir);
|
|
163
|
+
const { providerId, providerName } = resolveIdentity(agentDir);
|
|
164
|
+
const fast = new FastModeController(config.fast ?? false);
|
|
165
|
+
const pause = new PauseController(config.pause ?? false);
|
|
166
|
+
|
|
167
|
+
const connection = resolveExtensionConnection({
|
|
168
|
+
agentDir,
|
|
169
|
+
env: process.env,
|
|
170
|
+
providerId,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const defaultBaseUrl = defaultConnectionBaseUrl(agentDir, providerId);
|
|
174
|
+
|
|
175
|
+
const initialConnection =
|
|
176
|
+
connection ??
|
|
177
|
+
({
|
|
178
|
+
...resolveEndpoints(defaultBaseUrl),
|
|
179
|
+
apiKey: "login-required",
|
|
180
|
+
baseUrl: defaultBaseUrl,
|
|
181
|
+
} as const);
|
|
182
|
+
|
|
183
|
+
let activeContext: ExtensionContext | undefined;
|
|
184
|
+
let latestModels: readonly CliproxyModel[] = [];
|
|
185
|
+
let notifiedStaleModel: string | undefined;
|
|
186
|
+
// A price failure warns once per session, in the UI only. A warning before session_start waits for it.
|
|
187
|
+
let priceWarned = false;
|
|
188
|
+
let pendingPriceWarning: string | undefined;
|
|
189
|
+
|
|
190
|
+
const showPriceWarning = (ctx: ExtensionContext): void => {
|
|
191
|
+
if (priceWarned || !pendingPriceWarning || !ctx.hasUI) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
priceWarned = true;
|
|
196
|
+
ctx.ui.notify(pendingPriceWarning, "warning");
|
|
197
|
+
pendingPriceWarning = undefined;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const notifyStaleModel = (ctx: ExtensionContext): void => {
|
|
201
|
+
const stale = latestModels.find(
|
|
202
|
+
(model) =>
|
|
203
|
+
model.stale &&
|
|
204
|
+
((ctx.model?.provider === providerId && ctx.model.id === model.id) ||
|
|
205
|
+
isModelReferencedAsDefault(agentDir, providerId, model.id))
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
if (!stale) {
|
|
209
|
+
notifiedStaleModel = undefined;
|
|
210
|
+
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (notifiedStaleModel !== stale.id) {
|
|
215
|
+
notifiedStaleModel = stale.id;
|
|
216
|
+
ctx.ui.notify(
|
|
217
|
+
`Model '${stale.id}' is temporarily unavailable upstream. Retaining its cached entry.`,
|
|
218
|
+
"warning"
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const tracker = new UsageTracker({ agentDir, providerId });
|
|
224
|
+
const prices = new PriceCache({ agentDir });
|
|
225
|
+
|
|
226
|
+
const controller = createExtensionProvider({
|
|
227
|
+
agentDir,
|
|
228
|
+
config,
|
|
229
|
+
configured: connection !== null,
|
|
230
|
+
connection: initialConnection,
|
|
231
|
+
fast,
|
|
232
|
+
filterAvailable: (models) => tracker.filter(models),
|
|
233
|
+
onModels: (models) => {
|
|
234
|
+
latestModels = models;
|
|
235
|
+
|
|
236
|
+
if (activeContext) {
|
|
237
|
+
notifyStaleModel(activeContext);
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
onPriceWarning: (message) => {
|
|
241
|
+
if (priceWarned) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
pendingPriceWarning = message;
|
|
246
|
+
|
|
247
|
+
if (activeContext) {
|
|
248
|
+
showPriceWarning(activeContext);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
prices,
|
|
252
|
+
providerId,
|
|
253
|
+
providerName,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Print and JSON runs never refresh the catalog, so a cold start must load it before pi resolves --model.
|
|
257
|
+
if (
|
|
258
|
+
connection &&
|
|
259
|
+
controller.getModels().length === 0 &&
|
|
260
|
+
!OFFLINE_FLAG.test(process.env.PI_OFFLINE ?? "")
|
|
261
|
+
) {
|
|
262
|
+
try {
|
|
263
|
+
await controller.refresh(connection);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
// Startup has no UI yet, so stderr is the only place this warning can go.
|
|
266
|
+
console.warn(
|
|
267
|
+
`[cliproxyapi] Failed to load models: ${error instanceof Error ? error.message : String(error)}`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
latestModels = controller.getModels();
|
|
273
|
+
pi.registerProvider(controller.provider);
|
|
274
|
+
|
|
275
|
+
const footer = new FastFooterController(providerId, fast, pause);
|
|
276
|
+
footer.register(pi);
|
|
277
|
+
registerPause(pi, agentDir, pause);
|
|
278
|
+
registerTpsFooter(pi, pause, providerId);
|
|
279
|
+
registerTransientNetworkErrorRetry(pi, providerId);
|
|
280
|
+
|
|
281
|
+
const usage = registerUsage(pi, {
|
|
282
|
+
getCatalogIds: () => controller.getModels().map((model) => model.id),
|
|
283
|
+
providerId,
|
|
284
|
+
tracker,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
pi.registerCommand("cliproxyapi-reset", {
|
|
288
|
+
description:
|
|
289
|
+
"Reset CLIProxyAPI cooldowns, optionally for one model: /cliproxyapi-reset [model-id].",
|
|
290
|
+
getArgumentCompletions: (prefix) =>
|
|
291
|
+
completeFrom(
|
|
292
|
+
controller.getModels().map((model) => model.id),
|
|
293
|
+
prefix
|
|
294
|
+
),
|
|
295
|
+
handler: async (args, ctx) => {
|
|
296
|
+
const modelId = args.trim();
|
|
297
|
+
|
|
298
|
+
if (/\s/u.test(modelId)) {
|
|
299
|
+
ctx.ui.notify("Usage: /cliproxyapi-reset [model-id]", "error");
|
|
300
|
+
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!tracker.hasManagementKey()) {
|
|
305
|
+
ctx.ui.notify(
|
|
306
|
+
`Cannot reset CLIProxyAPI cooldowns: no management key is configured. Set ${MANAGEMENT_KEY_ENV} in the environment or "managementKey" in cliproxyapi.json, then try again.`,
|
|
307
|
+
"error"
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let affected: string[];
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
affected = await tracker.resetCooldowns(modelId || undefined);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
ctx.ui.notify(
|
|
319
|
+
`Failed to reset CLIProxyAPI cooldowns: ${error instanceof Error ? error.message : String(error)}`,
|
|
320
|
+
"error"
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
await usage.refresh(ctx);
|
|
328
|
+
await refreshRegistry(ctx, providerId);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
ctx.ui.notify(
|
|
331
|
+
`CLIProxyAPI cooldowns were reset, but refreshing models failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
332
|
+
"warning"
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (modelId && affected.length === 0) {
|
|
337
|
+
ctx.ui.notify(
|
|
338
|
+
`No CLIProxyAPI credential serves ${modelId}; nothing was reset for it.`,
|
|
339
|
+
"warning"
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
ctx.ui.notify(
|
|
346
|
+
affected.length > 0
|
|
347
|
+
? `Reset CLIProxyAPI cooldowns for: ${affected.join(", ")}.`
|
|
348
|
+
: "Reset CLIProxyAPI cooldowns for all credentials.",
|
|
349
|
+
"info"
|
|
350
|
+
);
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
registerCatalogCommands(
|
|
355
|
+
pi,
|
|
356
|
+
agentDir,
|
|
357
|
+
providerId,
|
|
358
|
+
fast,
|
|
359
|
+
controller,
|
|
360
|
+
footer,
|
|
361
|
+
usage
|
|
362
|
+
);
|
|
363
|
+
registerOAuthLogin(pi, {
|
|
364
|
+
providerId,
|
|
365
|
+
refresh: async (ctx) => {
|
|
366
|
+
await refreshRegistry(ctx, providerId);
|
|
367
|
+
await usage.refresh(ctx);
|
|
368
|
+
},
|
|
369
|
+
tracker,
|
|
370
|
+
});
|
|
371
|
+
registerWebSearch(pi, {
|
|
372
|
+
agentDir,
|
|
373
|
+
getAvailableModels: () => tracker.filter(controller.getModels()),
|
|
374
|
+
getModels: controller.getModels,
|
|
375
|
+
getStandardCost: controller.getStandardCost,
|
|
376
|
+
providerId,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Reads the files again on each call, so the status shows edits made by other pi processes.
|
|
380
|
+
const gatherStatus = (): StatusSnapshot => {
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
const current = loadCliproxyConfig(agentDir);
|
|
383
|
+
const models = controller.getModels();
|
|
384
|
+
const catalogFetchedAt = controller.getCatalogFetchedAt();
|
|
385
|
+
const priceInfo = prices.describe();
|
|
386
|
+
const management = tracker.managementStatus();
|
|
387
|
+
|
|
388
|
+
const sources = resolveConnectionSources(
|
|
389
|
+
process.env,
|
|
390
|
+
current,
|
|
391
|
+
loadStoredConnection(agentDir, providerId)
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const searchModel = pickSearchModel({
|
|
395
|
+
getStandardCost: controller.getStandardCost,
|
|
396
|
+
models: tracker.filter(models),
|
|
397
|
+
preferred: current.searchModel,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const ageSince = (time: number): number => Math.max(0, now - time);
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
baseUrl: defaultConnectionBaseUrl(agentDir, providerId),
|
|
404
|
+
catalogAgeMs:
|
|
405
|
+
catalogFetchedAt > 0 ? ageSince(catalogFetchedAt) : undefined,
|
|
406
|
+
fast: fast.isEnabled(),
|
|
407
|
+
fastModels: models.filter((model) => model.supportsFast).length,
|
|
408
|
+
keySource: sources.apiKey,
|
|
409
|
+
management: management && {
|
|
410
|
+
source: management.source,
|
|
411
|
+
usageAgeMs:
|
|
412
|
+
management.usageFetchedAt === undefined
|
|
413
|
+
? undefined
|
|
414
|
+
: ageSince(management.usageFetchedAt),
|
|
415
|
+
},
|
|
416
|
+
models: models.length,
|
|
417
|
+
paused: current.pause ?? pause.isEnabled(),
|
|
418
|
+
prices:
|
|
419
|
+
priceInfo?.fetchedAt === undefined
|
|
420
|
+
? undefined
|
|
421
|
+
: { ageMs: ageSince(priceInfo.fetchedAt), etag: priceInfo.etag },
|
|
422
|
+
searchModel: {
|
|
423
|
+
configured: current.searchModel,
|
|
424
|
+
effective: searchModel?.id,
|
|
425
|
+
},
|
|
426
|
+
stale: models.filter((model) => model.stale).length,
|
|
427
|
+
thinking: current.thinkingDisplay ?? {},
|
|
428
|
+
};
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
registerStatus(pi, gatherStatus);
|
|
432
|
+
|
|
433
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
434
|
+
activeContext = ctx;
|
|
435
|
+
notifyStaleModel(ctx);
|
|
436
|
+
showPriceWarning(ctx);
|
|
437
|
+
|
|
438
|
+
const currentConnection = resolveExtensionConnection({
|
|
439
|
+
agentDir,
|
|
440
|
+
env: process.env,
|
|
441
|
+
providerId,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
if (!currentConnection) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Not forced, so a catalog fetched under 10 minutes ago is reused without a request.
|
|
449
|
+
try {
|
|
450
|
+
const result = await ctx.modelRegistry.refresh({
|
|
451
|
+
providers: [providerId],
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const failure = result.errors.get(providerId);
|
|
455
|
+
|
|
456
|
+
if (failure) {
|
|
457
|
+
throw failure;
|
|
458
|
+
}
|
|
459
|
+
} catch (error) {
|
|
460
|
+
ctx.ui.notify(
|
|
461
|
+
`Failed to refresh CLIProxyAPI models: ${error instanceof Error ? error.message : String(error)}`,
|
|
462
|
+
"warning"
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
pi.on("model_select", (_event, ctx) => {
|
|
467
|
+
activeContext = ctx;
|
|
468
|
+
notifyStaleModel(ctx);
|
|
469
|
+
});
|
|
470
|
+
pi.on("session_shutdown", () => {
|
|
471
|
+
activeContext = undefined;
|
|
472
|
+
latestModels = [];
|
|
473
|
+
notifiedStaleModel = undefined;
|
|
474
|
+
priceWarned = false;
|
|
475
|
+
pendingPriceWarning = undefined;
|
|
476
|
+
});
|
|
477
|
+
}
|