dsh-cliproxyapi-pro 0.0.0-stage → 1.4.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/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +236 -2
- package/README.zh.md +237 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +9 -0
- package/lib/client.js +1867 -0
- package/lib/index.js +2249 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,2249 @@
|
|
|
1
|
+
// Zero-external-dependency host plugin for DSH CLIProxyAPI auto-discovery, model selection & sync.
|
|
2
|
+
// Uses only Node.js built-ins and Cordis context services (`ctx.settings`, `ctx.credentials`, `ctx.commands`, `ctx.tools`, `ctx.webServer`).
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
// #region Constants & Config Resolution
|
|
9
|
+
|
|
10
|
+
export const PACKAGE_NAME = "dsh-cliproxyapi-pro";
|
|
11
|
+
export const name = "dsh-cliproxyapi-pro";
|
|
12
|
+
export const inject = ["settings"];
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_ACCOUNT_ID = "default";
|
|
15
|
+
export const DEFAULT_PROVIDER = "cli-proxy";
|
|
16
|
+
export const DEFAULT_DISPLAY_NAME = "CLI Proxy API";
|
|
17
|
+
export const DEFAULT_API_KEY_ENV = "CLI_PROXY_API_KEY";
|
|
18
|
+
export const STATE_VERSION = 2;
|
|
19
|
+
const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
20
|
+
/** Per-account settings; everything else in the state file is global. */
|
|
21
|
+
export const ACCOUNT_FIELDS = [
|
|
22
|
+
"enabled",
|
|
23
|
+
"provider",
|
|
24
|
+
"displayName",
|
|
25
|
+
"baseURL",
|
|
26
|
+
"apiKeyEnv",
|
|
27
|
+
"protocol",
|
|
28
|
+
"protocolMode",
|
|
29
|
+
"includePrefix",
|
|
30
|
+
"excludePrefix",
|
|
31
|
+
"selectedModels",
|
|
32
|
+
"modelOverrides",
|
|
33
|
+
];
|
|
34
|
+
const GLOBAL_FIELDS = ["refreshIntervalSeconds", "probeTimeoutMs"];
|
|
35
|
+
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 300;
|
|
36
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 5000;
|
|
37
|
+
|
|
38
|
+
export const LOCAL_CANDIDATES = [
|
|
39
|
+
"http://127.0.0.1:8317",
|
|
40
|
+
"http://localhost:8317",
|
|
41
|
+
"http://[::1]:8317",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Most models {@link recommendModels} picks for an account that has no saved
|
|
46
|
+
* selection yet: one per model family, so the picker starts small.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_RECOMMENDED_MODELS = 12;
|
|
49
|
+
|
|
50
|
+
const MAX_CATALOG_BYTES = 8 * 1024 * 1024;
|
|
51
|
+
const PI_AI_NS = "llm-pi-ai";
|
|
52
|
+
const REASONING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
53
|
+
/** Wire protocols the DSH llm-pi-ai adapter accepts on a hand-declared route. */
|
|
54
|
+
export const PROTOCOLS = ["openai-completions", "openai-responses", "anthropic-messages"];
|
|
55
|
+
/** Route-key suffix and display-name label of each protocol's route. */
|
|
56
|
+
const PROTOCOL_ROUTES = {
|
|
57
|
+
"openai-completions": { suffix: "", label: "" },
|
|
58
|
+
"openai-responses": { suffix: "-responses", label: " (OpenAI Responses)" },
|
|
59
|
+
"anthropic-messages": { suffix: "-messages", label: " (Claude Messages)" },
|
|
60
|
+
};
|
|
61
|
+
const DEFAULT_MODEL_NS = "agent-default-model";
|
|
62
|
+
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
63
|
+
|
|
64
|
+
function toCredentialRef(value) {
|
|
65
|
+
const trimmed = String(value ?? "").trim();
|
|
66
|
+
if (!REF_PATTERN.test(trimmed)) {
|
|
67
|
+
throw new TypeError(`credential ref "${trimmed}" must match ${String(REF_PATTERN)}`);
|
|
68
|
+
}
|
|
69
|
+
return trimmed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getEnvValue(ctx, varName) {
|
|
73
|
+
try {
|
|
74
|
+
const launchEnv = ctx.get?.("launchEnvironment");
|
|
75
|
+
if (launchEnv && typeof launchEnv.get === "function") {
|
|
76
|
+
const hit = launchEnv.get(varName);
|
|
77
|
+
if (hit && typeof hit.value === "string" && hit.value.length > 0) {
|
|
78
|
+
return hit.value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch {}
|
|
82
|
+
const raw = process.env[varName];
|
|
83
|
+
return typeof raw === "string" && raw.length > 0 ? raw : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readVolatile(field, fallback) {
|
|
87
|
+
if (field !== undefined && field !== null && typeof field.get === "function") {
|
|
88
|
+
const val = field.get();
|
|
89
|
+
return val === undefined || val === null ? fallback : val;
|
|
90
|
+
}
|
|
91
|
+
return field === undefined || field === null ? fallback : field;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeStringList(value) {
|
|
95
|
+
if (!Array.isArray(value)) return undefined;
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const item of value) {
|
|
99
|
+
if (typeof item !== "string") continue;
|
|
100
|
+
const trimmed = item.trim();
|
|
101
|
+
if (trimmed !== "" && !seen.has(trimmed)) {
|
|
102
|
+
seen.add(trimmed);
|
|
103
|
+
out.push(trimmed);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const OVERRIDE_LIMITS = {
|
|
110
|
+
contextWindow: 10_000_000,
|
|
111
|
+
maxTokens: 2_000_000,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function normalizePositiveInteger(value, max) {
|
|
115
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
116
|
+
if (!/^\d+$/.test(value.trim())) return undefined;
|
|
117
|
+
value = Number(value.trim());
|
|
118
|
+
}
|
|
119
|
+
return typeof value === "number" &&
|
|
120
|
+
Number.isInteger(value) &&
|
|
121
|
+
value > 0 &&
|
|
122
|
+
value <= max
|
|
123
|
+
? value
|
|
124
|
+
: undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function normalizeModelOverrides(value) {
|
|
128
|
+
const raw = readVolatile(value, undefined);
|
|
129
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
|
130
|
+
const out = {};
|
|
131
|
+
for (const [id, entry] of Object.entries(raw)) {
|
|
132
|
+
if (typeof id !== "string" || id.trim() === "") continue;
|
|
133
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
|
134
|
+
const normalized = {};
|
|
135
|
+
const contextWindow = normalizePositiveInteger(entry.contextWindow, OVERRIDE_LIMITS.contextWindow);
|
|
136
|
+
const maxTokens = normalizePositiveInteger(entry.maxTokens, OVERRIDE_LIMITS.maxTokens);
|
|
137
|
+
if (contextWindow !== undefined) normalized.contextWindow = contextWindow;
|
|
138
|
+
if (maxTokens !== undefined) normalized.maxTokens = maxTokens;
|
|
139
|
+
if (entry.image === true || entry.image === false) normalized.image = entry.image;
|
|
140
|
+
if (typeof entry.protocol === "string" && PROTOCOLS.includes(entry.protocol)) {
|
|
141
|
+
normalized.protocol = entry.protocol;
|
|
142
|
+
}
|
|
143
|
+
if (Array.isArray(entry.reasoningLevels)) {
|
|
144
|
+
const levels = [];
|
|
145
|
+
for (const level of entry.reasoningLevels) {
|
|
146
|
+
if (typeof level !== "string") continue;
|
|
147
|
+
const mapped = level.trim().toLowerCase();
|
|
148
|
+
if (REASONING_LEVELS.includes(mapped) && !levels.includes(mapped)) levels.push(mapped);
|
|
149
|
+
}
|
|
150
|
+
normalized.reasoningLevels = levels;
|
|
151
|
+
}
|
|
152
|
+
if (Object.keys(normalized).length > 0) out[id.trim()] = normalized;
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function resolveConfig(rawConfig = {}) {
|
|
158
|
+
const providerRaw = String(readVolatile(rawConfig?.provider, DEFAULT_PROVIDER)).trim();
|
|
159
|
+
const provider = providerRaw || DEFAULT_PROVIDER;
|
|
160
|
+
const displayNameRaw = String(readVolatile(rawConfig?.displayName, DEFAULT_DISPLAY_NAME)).trim();
|
|
161
|
+
const displayName = displayNameRaw || DEFAULT_DISPLAY_NAME;
|
|
162
|
+
const apiKeyEnvRaw = String(readVolatile(rawConfig?.apiKeyEnv, DEFAULT_API_KEY_ENV)).trim();
|
|
163
|
+
const apiKeyEnv = apiKeyEnvRaw || DEFAULT_API_KEY_ENV;
|
|
164
|
+
const baseURL = String(readVolatile(rawConfig?.baseURL, "")).trim();
|
|
165
|
+
const protocolRaw = String(readVolatile(rawConfig?.protocol, "openai-completions")).trim();
|
|
166
|
+
const protocol =
|
|
167
|
+
protocolRaw === "openai-responses" || protocolRaw === "anthropic-messages"
|
|
168
|
+
? protocolRaw
|
|
169
|
+
: "openai-completions";
|
|
170
|
+
const protocolMode =
|
|
171
|
+
String(readVolatile(rawConfig?.protocolMode, "auto")).trim() === "fixed" ? "fixed" : "auto";
|
|
172
|
+
const refreshRaw = Number(
|
|
173
|
+
readVolatile(rawConfig?.refreshIntervalSeconds, DEFAULT_REFRESH_INTERVAL_SECONDS)
|
|
174
|
+
);
|
|
175
|
+
const refreshIntervalSeconds =
|
|
176
|
+
Number.isFinite(refreshRaw) && refreshRaw >= 15 && refreshRaw <= 86400
|
|
177
|
+
? Math.floor(refreshRaw)
|
|
178
|
+
: DEFAULT_REFRESH_INTERVAL_SECONDS;
|
|
179
|
+
const timeoutRaw = Number(readVolatile(rawConfig?.probeTimeoutMs, DEFAULT_PROBE_TIMEOUT_MS));
|
|
180
|
+
const probeTimeoutMs =
|
|
181
|
+
Number.isFinite(timeoutRaw) && timeoutRaw >= 250 && timeoutRaw <= 60000
|
|
182
|
+
? Math.floor(timeoutRaw)
|
|
183
|
+
: DEFAULT_PROBE_TIMEOUT_MS;
|
|
184
|
+
const includePrefix = String(readVolatile(rawConfig?.includePrefix, "")).trim();
|
|
185
|
+
const excludePrefix = String(readVolatile(rawConfig?.excludePrefix, "")).trim();
|
|
186
|
+
const selectedModels = normalizeStringList(readVolatile(rawConfig?.selectedModels, undefined));
|
|
187
|
+
const modelOverrides = normalizeModelOverrides(rawConfig?.modelOverrides);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
id: normalizeAccountId(readVolatile(rawConfig?.id, DEFAULT_ACCOUNT_ID)) ?? DEFAULT_ACCOUNT_ID,
|
|
191
|
+
enabled: readVolatile(rawConfig?.enabled, true) !== false,
|
|
192
|
+
provider,
|
|
193
|
+
displayName,
|
|
194
|
+
baseURL,
|
|
195
|
+
apiKeyEnv,
|
|
196
|
+
protocol,
|
|
197
|
+
protocolMode,
|
|
198
|
+
refreshIntervalSeconds,
|
|
199
|
+
probeTimeoutMs,
|
|
200
|
+
includePrefix,
|
|
201
|
+
excludePrefix,
|
|
202
|
+
...(selectedModels === undefined ? {} : { selectedModels }),
|
|
203
|
+
modelOverrides,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// #endregion
|
|
208
|
+
|
|
209
|
+
// #region Accounts
|
|
210
|
+
|
|
211
|
+
export function normalizeAccountId(value) {
|
|
212
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
213
|
+
return ACCOUNT_ID_PATTERN.test(trimmed) ? trimmed : undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The persisted shape of one account: its settings without the globals. */
|
|
217
|
+
export function pickAccount(resolved) {
|
|
218
|
+
return {
|
|
219
|
+
id: resolved.id,
|
|
220
|
+
enabled: resolved.enabled,
|
|
221
|
+
provider: resolved.provider,
|
|
222
|
+
displayName: resolved.displayName,
|
|
223
|
+
baseURL: resolved.baseURL,
|
|
224
|
+
apiKeyEnv: resolved.apiKeyEnv,
|
|
225
|
+
protocol: resolved.protocol,
|
|
226
|
+
protocolMode: resolved.protocolMode,
|
|
227
|
+
includePrefix: resolved.includePrefix,
|
|
228
|
+
excludePrefix: resolved.excludePrefix,
|
|
229
|
+
...(resolved.selectedModels === undefined ? {} : { selectedModels: resolved.selectedModels }),
|
|
230
|
+
modelOverrides: resolved.modelOverrides,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function pickFields(source, fields) {
|
|
235
|
+
const out = {};
|
|
236
|
+
if (typeof source !== "object" || source === null) return out;
|
|
237
|
+
for (const field of fields) if (source[field] !== undefined) out[field] = source[field];
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function plainObject(value) {
|
|
242
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Normalize the state file into `{ version, refreshIntervalSeconds,
|
|
247
|
+
* probeTimeoutMs, accounts[] }`. A legacy single-account file (flat fields,
|
|
248
|
+
* no `accounts` array) becomes one account with id `default`, so its
|
|
249
|
+
* provider route, key reference and model selection carry over unchanged.
|
|
250
|
+
* Invalid or duplicate account ids are replaced with generated ones.
|
|
251
|
+
*/
|
|
252
|
+
export function normalizeState(raw = {}) {
|
|
253
|
+
const source = plainObject(raw) ?? {};
|
|
254
|
+
const globals = resolveConfig(pickFields(source, GLOBAL_FIELDS));
|
|
255
|
+
const rawAccounts = Array.isArray(source.accounts)
|
|
256
|
+
? source.accounts
|
|
257
|
+
: [{ ...pickFields(source, ACCOUNT_FIELDS), id: DEFAULT_ACCOUNT_ID }];
|
|
258
|
+
const accounts = [];
|
|
259
|
+
const ids = new Set();
|
|
260
|
+
for (const entry of rawAccounts) {
|
|
261
|
+
if (plainObject(entry) === undefined) continue;
|
|
262
|
+
let id = normalizeAccountId(entry.id);
|
|
263
|
+
if (id === undefined || ids.has(id)) {
|
|
264
|
+
let n = accounts.length + 1;
|
|
265
|
+
while (ids.has(`account-${n}`)) n += 1;
|
|
266
|
+
id = `account-${n}`;
|
|
267
|
+
}
|
|
268
|
+
ids.add(id);
|
|
269
|
+
accounts.push(pickAccount(resolveConfig({ ...pickFields(entry, ACCOUNT_FIELDS), id })));
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
version: STATE_VERSION,
|
|
273
|
+
refreshIntervalSeconds: globals.refreshIntervalSeconds,
|
|
274
|
+
probeTimeoutMs: globals.probeTimeoutMs,
|
|
275
|
+
accounts,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Overlay the host-supplied plugin config on the persisted state. A config
|
|
281
|
+
* with `accounts` replaces the account list; flat legacy fields apply to the
|
|
282
|
+
* first account, as they did when only one account existed.
|
|
283
|
+
*/
|
|
284
|
+
export function mergeHostConfig(persisted, config) {
|
|
285
|
+
const host = plainObject(config) ?? {};
|
|
286
|
+
const base = plainObject(persisted) ?? {};
|
|
287
|
+
if (!Array.isArray(base.accounts) || Array.isArray(host.accounts)) return { ...base, ...host };
|
|
288
|
+
const flat = pickFields(host, ACCOUNT_FIELDS);
|
|
289
|
+
const accounts = [...base.accounts];
|
|
290
|
+
if (Object.keys(flat).length > 0 && accounts.length > 0) accounts[0] = { ...accounts[0], ...flat };
|
|
291
|
+
return { ...base, ...pickFields(host, GLOBAL_FIELDS), accounts };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Accounts whose managed route keys overlap an earlier account's. Each one
|
|
296
|
+
* maps to the id of the account that already owns the keys.
|
|
297
|
+
*/
|
|
298
|
+
export function accountConflicts(accounts) {
|
|
299
|
+
const owners = new Map();
|
|
300
|
+
const conflicts = new Map();
|
|
301
|
+
for (const account of accounts) {
|
|
302
|
+
const keys = managedRouteKeys(account.provider);
|
|
303
|
+
const clash = keys.map((key) => owners.get(key)).find((owner) => owner !== undefined);
|
|
304
|
+
if (clash !== undefined) {
|
|
305
|
+
conflicts.set(account.id, clash);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
for (const key of keys) owners.set(key, account.id);
|
|
309
|
+
}
|
|
310
|
+
return conflicts;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function assertNoConflicts(accounts) {
|
|
314
|
+
const conflicts = accountConflicts(accounts);
|
|
315
|
+
for (const [id, ownerId] of conflicts) {
|
|
316
|
+
const account = accounts.find((a) => a.id === id);
|
|
317
|
+
const owner = accounts.find((a) => a.id === ownerId);
|
|
318
|
+
throw new Error(
|
|
319
|
+
`dsh-cliproxyapi: provider route "${account?.provider}" of account "${account?.displayName ?? id}" overlaps the routes of account "${owner?.displayName ?? ownerId}". Every account needs its own Provider Route ID.`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Defaults for a newly added account: the first free `cli-proxy-N` route,
|
|
326
|
+
* its own credential reference, and disabled until the user fills it in.
|
|
327
|
+
* When no account owns `cli-proxy`, the new account takes the classic names.
|
|
328
|
+
*/
|
|
329
|
+
export function newAccountDefaults(accounts) {
|
|
330
|
+
const usedIds = new Set(accounts.map((a) => a.id));
|
|
331
|
+
const usedKeys = new Set(accounts.flatMap((a) => managedRouteKeys(a.provider)));
|
|
332
|
+
const usedRefs = new Set(accounts.map((a) => a.apiKeyEnv));
|
|
333
|
+
const free = (provider) => managedRouteKeys(provider).every((key) => !usedKeys.has(key));
|
|
334
|
+
const base = {
|
|
335
|
+
enabled: false,
|
|
336
|
+
baseURL: "",
|
|
337
|
+
protocol: "openai-completions",
|
|
338
|
+
protocolMode: "auto",
|
|
339
|
+
includePrefix: "",
|
|
340
|
+
excludePrefix: "",
|
|
341
|
+
// No selectedModels: the first successful probe seeds recommendModels().
|
|
342
|
+
modelOverrides: {},
|
|
343
|
+
};
|
|
344
|
+
if (!usedIds.has(DEFAULT_ACCOUNT_ID) && free(DEFAULT_PROVIDER) && !usedRefs.has(DEFAULT_API_KEY_ENV)) {
|
|
345
|
+
return {
|
|
346
|
+
...base,
|
|
347
|
+
id: DEFAULT_ACCOUNT_ID,
|
|
348
|
+
provider: DEFAULT_PROVIDER,
|
|
349
|
+
displayName: DEFAULT_DISPLAY_NAME,
|
|
350
|
+
apiKeyEnv: DEFAULT_API_KEY_ENV,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
let n = Math.max(2, accounts.length + 1);
|
|
354
|
+
while (
|
|
355
|
+
usedIds.has(`account-${n}`) ||
|
|
356
|
+
!free(`${DEFAULT_PROVIDER}-${n}`) ||
|
|
357
|
+
usedRefs.has(`${DEFAULT_API_KEY_ENV}_${n}`)
|
|
358
|
+
) {
|
|
359
|
+
n += 1;
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
...base,
|
|
363
|
+
id: `account-${n}`,
|
|
364
|
+
provider: `${DEFAULT_PROVIDER}-${n}`,
|
|
365
|
+
displayName: `${DEFAULT_DISPLAY_NAME} ${n}`,
|
|
366
|
+
apiKeyEnv: `${DEFAULT_API_KEY_ENV}_${n}`,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Apply an account patch from the UI or the tool. `selectedModels` and
|
|
372
|
+
* `modelOverrides` replace the stored value when present; the id never changes.
|
|
373
|
+
*/
|
|
374
|
+
export function patchAccount(account, patch) {
|
|
375
|
+
const fields = pickFields(patch, ACCOUNT_FIELDS);
|
|
376
|
+
const next = { ...account, ...fields, id: account.id };
|
|
377
|
+
if (patch !== null && typeof patch === "object" && "selectedModels" in patch) {
|
|
378
|
+
next.selectedModels = Array.isArray(patch.selectedModels)
|
|
379
|
+
? normalizeStringList(patch.selectedModels) ?? []
|
|
380
|
+
: [];
|
|
381
|
+
}
|
|
382
|
+
if (patch !== null && typeof patch === "object" && "modelOverrides" in patch) {
|
|
383
|
+
next.modelOverrides = normalizeModelOverrides(patch.modelOverrides);
|
|
384
|
+
}
|
|
385
|
+
return pickAccount(resolveConfig(next));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// #endregion
|
|
389
|
+
|
|
390
|
+
// #region Persistent State Store
|
|
391
|
+
|
|
392
|
+
export function resolveStateFilePath(ctx) {
|
|
393
|
+
try {
|
|
394
|
+
const profileCtx = ctx?.get?.("profileContext");
|
|
395
|
+
if (profileCtx && typeof profileCtx.dir === "string" && profileCtx.dir.trim() !== "") {
|
|
396
|
+
return join(profileCtx.dir.trim(), ".dsh-cliproxyapi", "state.json");
|
|
397
|
+
}
|
|
398
|
+
} catch {}
|
|
399
|
+
const dshHome =
|
|
400
|
+
process.env.DSH_HOME && process.env.DSH_HOME.trim() !== ""
|
|
401
|
+
? process.env.DSH_HOME.trim()
|
|
402
|
+
: join(homedir(), ".dsh");
|
|
403
|
+
return join(dshHome, "dsh-cliproxyapi", "state.json");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function loadPersistedState(filePath) {
|
|
407
|
+
try {
|
|
408
|
+
if (!existsSync(filePath)) return {};
|
|
409
|
+
const raw = JSON.parse(readFileSync(filePath, "utf8"));
|
|
410
|
+
return typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {};
|
|
411
|
+
} catch {
|
|
412
|
+
return {};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function savePersistedState(filePath, state) {
|
|
417
|
+
try {
|
|
418
|
+
const dir = dirname(filePath);
|
|
419
|
+
mkdirSync(dir, { recursive: true });
|
|
420
|
+
const tmp = `${filePath}.${Date.now().toString(36)}.tmp`;
|
|
421
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
|
|
422
|
+
renameSync(tmp, filePath);
|
|
423
|
+
} catch {}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// #endregion
|
|
427
|
+
|
|
428
|
+
// #region Discovery & Catalog Parsing
|
|
429
|
+
|
|
430
|
+
function positiveInteger(value) {
|
|
431
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function firstPositiveInteger(source, fields) {
|
|
435
|
+
for (const field of fields) {
|
|
436
|
+
const value = positiveInteger(source[field]);
|
|
437
|
+
if (value !== undefined) return value;
|
|
438
|
+
}
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function stringArray(value) {
|
|
443
|
+
if (!Array.isArray(value)) return [];
|
|
444
|
+
return [
|
|
445
|
+
...new Set(
|
|
446
|
+
value
|
|
447
|
+
.filter((item) => typeof item === "string")
|
|
448
|
+
.map((item) => item.trim())
|
|
449
|
+
.filter(Boolean)
|
|
450
|
+
),
|
|
451
|
+
];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function catalogArray(payload) {
|
|
455
|
+
if (typeof payload !== "object" || payload === null) return undefined;
|
|
456
|
+
if (Array.isArray(payload.models)) return payload.models;
|
|
457
|
+
if (Array.isArray(payload.data)) return payload.data;
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function rawReasoningEfforts(value) {
|
|
462
|
+
if (!Array.isArray(value)) return [];
|
|
463
|
+
return value
|
|
464
|
+
.map((item) =>
|
|
465
|
+
typeof item === "string"
|
|
466
|
+
? item
|
|
467
|
+
: typeof item === "object" && item !== null && typeof item.effort === "string"
|
|
468
|
+
? item.effort
|
|
469
|
+
: ""
|
|
470
|
+
)
|
|
471
|
+
.map((raw) => raw.trim().toLowerCase())
|
|
472
|
+
.filter(Boolean);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function parseReasoningLevels(value) {
|
|
476
|
+
const levels = [];
|
|
477
|
+
for (const normalized of rawReasoningEfforts(value)) {
|
|
478
|
+
const mapped =
|
|
479
|
+
normalized === "none" ? "off" : normalized === "ultra" ? "max" : normalized;
|
|
480
|
+
if (REASONING_LEVELS.includes(mapped) && !levels.includes(mapped)) {
|
|
481
|
+
levels.push(mapped);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return levels;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Wire spellings that differ from the DSH level name: a catalog offering only
|
|
489
|
+
* `ultra` sends `ultra` for DSH `max`; one also offering `max` keeps `max`.
|
|
490
|
+
*/
|
|
491
|
+
function parseReasoningWire(value) {
|
|
492
|
+
const raw = new Set(rawReasoningEfforts(value));
|
|
493
|
+
return raw.has("ultra") && !raw.has("max") ? { max: "ultra" } : {};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function normalizeCpaRoot(raw) {
|
|
497
|
+
const trimmed = String(raw ?? "").trim();
|
|
498
|
+
if (trimmed === "") throw new Error("empty CPA baseURL");
|
|
499
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
|
500
|
+
const url = new URL(withScheme);
|
|
501
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
502
|
+
throw new Error(`unsupported protocol ${url.protocol}`);
|
|
503
|
+
}
|
|
504
|
+
url.search = "";
|
|
505
|
+
url.hash = "";
|
|
506
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
507
|
+
path = path.replace(/\/(?:v1(?:\/messages|\/chat\/completions|\/responses)?|backend-api)$/i, "");
|
|
508
|
+
url.pathname = path === "" ? "/" : `${path}/`;
|
|
509
|
+
return url.toString().replace(/\/$/, "");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Probe order: the configured URL, then (only with `autoDiscover`, the
|
|
514
|
+
* default) the account's existing route, `CLIPROXYAPI_BASE_URL` and the local
|
|
515
|
+
* ports. Accounts with an explicit URL never fall back to another server, so
|
|
516
|
+
* one account can't silently mirror another.
|
|
517
|
+
*/
|
|
518
|
+
export function discoveryCandidates(options) {
|
|
519
|
+
const raw =
|
|
520
|
+
options.autoDiscover === false
|
|
521
|
+
? [options.baseURL]
|
|
522
|
+
: [options.baseURL, options.fallbackBaseURL, options.environmentBaseURL, ...LOCAL_CANDIDATES];
|
|
523
|
+
const result = [];
|
|
524
|
+
for (const candidate of raw) {
|
|
525
|
+
if (candidate === undefined || String(candidate).trim() === "") continue;
|
|
526
|
+
try {
|
|
527
|
+
const normalized = normalizeCpaRoot(candidate);
|
|
528
|
+
if (!result.includes(normalized)) result.push(normalized);
|
|
529
|
+
} catch {
|
|
530
|
+
if (candidate === options.baseURL && String(options.baseURL).trim() !== "") {
|
|
531
|
+
throw new Error(`dsh-cliproxyapi: invalid baseURL "${candidate}"`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function parseCatalog(payload) {
|
|
539
|
+
const catalog = catalogArray(payload);
|
|
540
|
+
if (catalog === undefined) {
|
|
541
|
+
throw new Error("response does not contain a models or data array");
|
|
542
|
+
}
|
|
543
|
+
const models = new Map();
|
|
544
|
+
for (const item of catalog) {
|
|
545
|
+
if (typeof item !== "object" || item === null) continue;
|
|
546
|
+
if (String(item.visibility ?? "").toLowerCase() === "hide") continue;
|
|
547
|
+
const rawId = item.slug ?? item.id;
|
|
548
|
+
const id = typeof rawId === "string" ? rawId.trim() : "";
|
|
549
|
+
if (id === "" || models.has(id)) continue;
|
|
550
|
+
const inputModalities = stringArray(item.input_modalities ?? item.inputModalities).map((v) =>
|
|
551
|
+
v.toLowerCase()
|
|
552
|
+
);
|
|
553
|
+
const rawLevels = item.supported_reasoning_levels ?? item.reasoning_levels;
|
|
554
|
+
const levels = parseReasoningLevels(rawLevels);
|
|
555
|
+
const reasoningWire = parseReasoningWire(rawLevels);
|
|
556
|
+
const contextWindow = firstPositiveInteger(item, [
|
|
557
|
+
"context_window",
|
|
558
|
+
"max_context_window",
|
|
559
|
+
"context_length",
|
|
560
|
+
"contextWindow",
|
|
561
|
+
]);
|
|
562
|
+
const maxTokens = firstPositiveInteger(item, [
|
|
563
|
+
"max_output_tokens",
|
|
564
|
+
"max_tokens",
|
|
565
|
+
"maxTokens",
|
|
566
|
+
]);
|
|
567
|
+
const rawName = item.display_name ?? item.name;
|
|
568
|
+
models.set(id, {
|
|
569
|
+
id,
|
|
570
|
+
...(typeof rawName === "string" && rawName.trim() !== "" ? { name: rawName.trim() } : {}),
|
|
571
|
+
...(typeof item.owned_by === "string" && item.owned_by.trim() !== ""
|
|
572
|
+
? { ownedBy: item.owned_by.trim() }
|
|
573
|
+
: {}),
|
|
574
|
+
...(typeof item.provider === "string" && item.provider.trim() !== ""
|
|
575
|
+
? { provider: item.provider.trim() }
|
|
576
|
+
: {}),
|
|
577
|
+
...(contextWindow === undefined ? {} : { contextWindow }),
|
|
578
|
+
...(maxTokens === undefined ? {} : { maxTokens }),
|
|
579
|
+
inputModalities: inputModalities.length > 0 ? inputModalities : ["text"],
|
|
580
|
+
reasoning: item.reasoning === true || levels.some((level) => level !== "off"),
|
|
581
|
+
reasoningLevels: levels,
|
|
582
|
+
...(Object.keys(reasoningWire).length === 0 ? {} : { reasoningWire }),
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return [...models.values()];
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function readBoundedJson(response) {
|
|
589
|
+
const declared = Number(response.headers.get("content-length"));
|
|
590
|
+
if (Number.isFinite(declared) && declared > MAX_CATALOG_BYTES) {
|
|
591
|
+
throw new Error("catalog is larger than 8 MiB");
|
|
592
|
+
}
|
|
593
|
+
const text = await response.text();
|
|
594
|
+
if (new TextEncoder().encode(text).byteLength > MAX_CATALOG_BYTES) {
|
|
595
|
+
throw new Error("catalog is larger than 8 MiB");
|
|
596
|
+
}
|
|
597
|
+
try {
|
|
598
|
+
return JSON.parse(text);
|
|
599
|
+
} catch {
|
|
600
|
+
throw new Error("catalog is not valid JSON");
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function fetchOwners(fetchImpl, rootURL, options, signal) {
|
|
605
|
+
const owners = new Map();
|
|
606
|
+
try {
|
|
607
|
+
const response = await fetchImpl(`${rootURL}/v1/models`, {
|
|
608
|
+
method: "GET",
|
|
609
|
+
headers: {
|
|
610
|
+
Accept: "application/json",
|
|
611
|
+
...(options.apiKey === undefined || options.apiKey === ""
|
|
612
|
+
? {}
|
|
613
|
+
: { Authorization: `Bearer ${options.apiKey}` }),
|
|
614
|
+
},
|
|
615
|
+
signal,
|
|
616
|
+
});
|
|
617
|
+
if (!response.ok) return owners;
|
|
618
|
+
const list = catalogArray(await readBoundedJson(response)) ?? [];
|
|
619
|
+
for (const item of list) {
|
|
620
|
+
const id = typeof item?.id === "string" ? item.id.trim() : "";
|
|
621
|
+
const owner = typeof item?.owned_by === "string" ? item.owned_by.trim() : "";
|
|
622
|
+
if (id !== "" && owner !== "") owners.set(id, owner);
|
|
623
|
+
}
|
|
624
|
+
} catch (error) {
|
|
625
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
626
|
+
}
|
|
627
|
+
return owners;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export async function discoverCpa(options) {
|
|
631
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
632
|
+
const failures = [];
|
|
633
|
+
const candidates = discoveryCandidates(options);
|
|
634
|
+
for (const rootURL of candidates) {
|
|
635
|
+
const modelsURL = `${rootURL}/v1/models?client_version=pi`;
|
|
636
|
+
const timeout = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS);
|
|
637
|
+
const signal =
|
|
638
|
+
options.signal === undefined ? timeout : AbortSignal.any([options.signal, timeout]);
|
|
639
|
+
try {
|
|
640
|
+
const response = await fetchImpl(modelsURL, {
|
|
641
|
+
method: "GET",
|
|
642
|
+
headers: {
|
|
643
|
+
Accept: "application/json",
|
|
644
|
+
...(options.apiKey === undefined || options.apiKey === ""
|
|
645
|
+
? {}
|
|
646
|
+
: { Authorization: `Bearer ${options.apiKey}` }),
|
|
647
|
+
},
|
|
648
|
+
signal,
|
|
649
|
+
});
|
|
650
|
+
if (!response.ok) {
|
|
651
|
+
failures.push(`${rootURL}: HTTP ${response.status}`);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const models = parseCatalog(await readBoundedJson(response));
|
|
655
|
+
// The enriched catalog omits `owned_by`; the plain listing carries the
|
|
656
|
+
// model family each upstream reports, which decides its native protocol.
|
|
657
|
+
const owners = await fetchOwners(fetchImpl, rootURL, options, signal);
|
|
658
|
+
return {
|
|
659
|
+
rootURL,
|
|
660
|
+
modelsURL,
|
|
661
|
+
models: models.map((m) =>
|
|
662
|
+
m.ownedBy === undefined && owners.has(m.id) ? { ...m, ownedBy: owners.get(m.id) } : m
|
|
663
|
+
),
|
|
664
|
+
};
|
|
665
|
+
} catch (error) {
|
|
666
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
667
|
+
failures.push(`${rootURL}: ${error instanceof Error ? error.message : String(error)}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
throw new Error(`dsh-cliproxyapi: no usable CPA service found (${failures.join("; ")})`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// #endregion
|
|
674
|
+
|
|
675
|
+
// #region Profile & Model Builder
|
|
676
|
+
|
|
677
|
+
function splitPrefixes(csv) {
|
|
678
|
+
if (typeof csv !== "string" || csv.trim() === "") return [];
|
|
679
|
+
return csv
|
|
680
|
+
.split(",")
|
|
681
|
+
.map((s) => s.trim().toLowerCase())
|
|
682
|
+
.filter(Boolean);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function filterModels(models, config) {
|
|
686
|
+
const includes = splitPrefixes(config.includePrefix);
|
|
687
|
+
const excludes = splitPrefixes(config.excludePrefix);
|
|
688
|
+
const selectedSet =
|
|
689
|
+
Array.isArray(config.selectedModels) && config.selectedModels.length > 0
|
|
690
|
+
? new Set(config.selectedModels)
|
|
691
|
+
: null;
|
|
692
|
+
|
|
693
|
+
return models.filter((m) => {
|
|
694
|
+
if (selectedSet !== null && !selectedSet.has(m.id)) {
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
const idLower = m.id.toLowerCase();
|
|
698
|
+
if (includes.length > 0 && !includes.some((p) => idLower.startsWith(p) || idLower.includes(p))) {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
if (excludes.length > 0 && excludes.some((p) => idLower.startsWith(p) || idLower.includes(p))) {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
return true;
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export function modelFamily(model) {
|
|
709
|
+
const label = (model.name ?? model.id).trim().toLowerCase();
|
|
710
|
+
const known = label.match(
|
|
711
|
+
/^(gpt-oss|gemini|claude|gpt|grok|deepseek|qwen|glm|kimi|minimax|mistral|llama|swe|o(?=\d))/
|
|
712
|
+
)?.[1];
|
|
713
|
+
if (known !== undefined) return known;
|
|
714
|
+
return label.split(/[\s_/-]*\d/, 1)[0]?.replace(/[\s_/-]+$/, "") || label;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function modelVersion(model) {
|
|
718
|
+
const labels = [model.name, model.id];
|
|
719
|
+
for (const label of labels) {
|
|
720
|
+
if (label === undefined) continue;
|
|
721
|
+
const match = label.match(/(?:^|[^\d])(\d+(?:[.-]\d+)*)/);
|
|
722
|
+
if (match?.[1] !== undefined) return match[1].split(/[.-]/).map(Number);
|
|
723
|
+
}
|
|
724
|
+
return [];
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function compareVersionDescending(left, right) {
|
|
728
|
+
const a = modelVersion(left);
|
|
729
|
+
const b = modelVersion(right);
|
|
730
|
+
const length = Math.max(a.length, b.length);
|
|
731
|
+
for (let i = 0; i < length; i += 1) {
|
|
732
|
+
const diff = (b[i] ?? 0) - (a[i] ?? 0);
|
|
733
|
+
if (diff !== 0) return diff;
|
|
734
|
+
}
|
|
735
|
+
return 0;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export function sortModelsNewestFirst(models) {
|
|
739
|
+
const groups = new Map();
|
|
740
|
+
for (const model of models) {
|
|
741
|
+
const family = modelFamily(model);
|
|
742
|
+
const group = groups.get(family);
|
|
743
|
+
if (group === undefined) groups.set(family, [model]);
|
|
744
|
+
else group.push(model);
|
|
745
|
+
}
|
|
746
|
+
return [...groups.values()].flatMap((group) => [...group].sort(compareVersionDescending));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* A starting selection derived from the server's own catalog: the newest
|
|
751
|
+
* model of each family, in catalog order, at most {@link MAX_RECOMMENDED_MODELS}.
|
|
752
|
+
* Among equally new models a plain id is preferred over a prefixed one
|
|
753
|
+
* (`gpt-6` over `vendor/gpt-6`).
|
|
754
|
+
* @param models - parsed catalog models.
|
|
755
|
+
* @returns model ids.
|
|
756
|
+
*/
|
|
757
|
+
export function recommendModels(models) {
|
|
758
|
+
const groups = new Map();
|
|
759
|
+
for (const model of models) {
|
|
760
|
+
const family = modelFamily(model);
|
|
761
|
+
const group = groups.get(family);
|
|
762
|
+
if (group === undefined) groups.set(family, [model]);
|
|
763
|
+
else group.push(model);
|
|
764
|
+
}
|
|
765
|
+
const picks = [];
|
|
766
|
+
for (const group of groups.values()) {
|
|
767
|
+
const sorted = [...group].sort(
|
|
768
|
+
(a, b) => compareVersionDescending(a, b) || Number(a.id.includes("/")) - Number(b.id.includes("/"))
|
|
769
|
+
);
|
|
770
|
+
picks.push(sorted[0].id);
|
|
771
|
+
if (picks.length >= MAX_RECOMMENDED_MODELS) break;
|
|
772
|
+
}
|
|
773
|
+
return picks;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Pick the wire protocol that reaches a model with the fewest translations.
|
|
778
|
+
*
|
|
779
|
+
* CLIProxyAPI serves every model on OpenAI Chat, OpenAI Responses and
|
|
780
|
+
* Anthropic Messages and translates between them and the upstream channel.
|
|
781
|
+
* Speaking the upstream's own protocol skips that translation:
|
|
782
|
+
* - Claude family -> Anthropic Messages (`/v1/messages`)
|
|
783
|
+
* - OpenAI / Codex / Grok -> OpenAI Responses (`/v1/responses`)
|
|
784
|
+
* - everything else, including Gemini -> OpenAI Chat Completions; DSH
|
|
785
|
+
* cannot speak Gemini generateContent on a custom route.
|
|
786
|
+
* `owned_by` from the CPA listing names the family; the id is the fallback.
|
|
787
|
+
* @param model - parsed catalog model.
|
|
788
|
+
* @returns one of {@link PROTOCOLS}.
|
|
789
|
+
*/
|
|
790
|
+
export function nativeProtocol(model) {
|
|
791
|
+
const owner = String(model.ownedBy ?? "").toLowerCase();
|
|
792
|
+
const bare = String(model.id ?? "").toLowerCase().split("/").pop() ?? "";
|
|
793
|
+
if (owner === "anthropic" || /^claude/.test(bare)) return "anthropic-messages";
|
|
794
|
+
if (owner === "openai" || owner === "xai") return "openai-responses";
|
|
795
|
+
if (/^(gpt|o\d|codex|grok)/.test(bare) && !/^gpt-oss/.test(bare)) return "openai-responses";
|
|
796
|
+
return "openai-completions";
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** The protocol a model is synchronized with under the given config. */
|
|
800
|
+
export function modelProtocol(model, config) {
|
|
801
|
+
const override = config.modelOverrides?.[model.id]?.protocol;
|
|
802
|
+
if (override !== undefined) return override;
|
|
803
|
+
return config.protocolMode === "fixed" ? config.protocol : nativeProtocol(model);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** The DSH route key carrying one protocol. */
|
|
807
|
+
export function routeKeyFor(provider, protocol) {
|
|
808
|
+
return `${provider}${PROTOCOL_ROUTES[protocol].suffix}`;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function routeBaseURL(rootURL, protocol) {
|
|
812
|
+
return protocol === "anthropic-messages" ? rootURL : `${rootURL}/v1`;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function supportedReasoning(model) {
|
|
816
|
+
if (!model.reasoning) return undefined;
|
|
817
|
+
const declared = new Set(model.reasoningLevels);
|
|
818
|
+
const levels =
|
|
819
|
+
declared.size === 0
|
|
820
|
+
? REASONING_LEVELS
|
|
821
|
+
: REASONING_LEVELS.filter((level) => declared.has(level));
|
|
822
|
+
const result = {};
|
|
823
|
+
for (const level of levels) {
|
|
824
|
+
result[level] = level === "off" ? null : model.reasoningWire?.[level] ?? level;
|
|
825
|
+
}
|
|
826
|
+
return Object.keys(result).length === 0 ? undefined : result;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Claude models that take adaptive thinking with an `effort` value rather
|
|
831
|
+
* than a token budget: Opus/Sonnet 4.6+, Haiku 5+, Fable and every 5.x.
|
|
832
|
+
* Older Claude models keep budget-based thinking.
|
|
833
|
+
*/
|
|
834
|
+
function claudeUsesAdaptiveThinking(model) {
|
|
835
|
+
const bare = String(model.id).toLowerCase().split("/").pop() ?? "";
|
|
836
|
+
if (/^claude-fable/.test(bare)) return true;
|
|
837
|
+
const match = bare.match(/^claude-(?:opus|sonnet|haiku)-(\d+)(?:-(\d+))?/);
|
|
838
|
+
if (match === null) return /^claude-\d+-fable/.test(bare);
|
|
839
|
+
const major = Number(match[1]);
|
|
840
|
+
const minor = match[2] !== undefined && match[2].length <= 2 ? Number(match[2]) : 0;
|
|
841
|
+
return major > 4 || (major === 4 && minor >= 6);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
export function applyModelOverride(model, override) {
|
|
845
|
+
if (override === undefined) return model;
|
|
846
|
+
const next = { ...model };
|
|
847
|
+
if (override.contextWindow !== undefined) next.contextWindow = override.contextWindow;
|
|
848
|
+
if (override.maxTokens !== undefined) next.maxTokens = override.maxTokens;
|
|
849
|
+
if (override.image === true || override.image === false) {
|
|
850
|
+
const rest = model.inputModalities.filter((item) => item !== "image");
|
|
851
|
+
next.inputModalities = override.image ? [...new Set(["text", "image", ...rest])] : rest.length > 0 ? rest : ["text"];
|
|
852
|
+
}
|
|
853
|
+
if (Array.isArray(override.reasoningLevels)) {
|
|
854
|
+
next.reasoningLevels = [...override.reasoningLevels];
|
|
855
|
+
next.reasoning = override.reasoningLevels.some((level) => level !== "off");
|
|
856
|
+
}
|
|
857
|
+
return next;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function protocolCompat(model, protocol, hasReasoning) {
|
|
861
|
+
if (protocol === "openai-completions") {
|
|
862
|
+
return hasReasoning ? { supportsReasoningEffort: true, thinkingFormat: "openai" } : undefined;
|
|
863
|
+
}
|
|
864
|
+
if (protocol === "anthropic-messages") {
|
|
865
|
+
return hasReasoning && claudeUsesAdaptiveThinking(model)
|
|
866
|
+
? { forceAdaptiveThinking: true }
|
|
867
|
+
: undefined;
|
|
868
|
+
}
|
|
869
|
+
return undefined;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function toPiAiModelProfile(model, protocol) {
|
|
873
|
+
const reasoningEfforts = supportedReasoning(model);
|
|
874
|
+
const compat = protocolCompat(model, protocol, reasoningEfforts !== undefined);
|
|
875
|
+
return {
|
|
876
|
+
id: model.id,
|
|
877
|
+
...(model.name === undefined ? {} : { name: model.name }),
|
|
878
|
+
contextWindow: model.contextWindow ?? 262144,
|
|
879
|
+
maxTokens: model.maxTokens ?? 64000,
|
|
880
|
+
input: model.inputModalities.includes("image") ? ["text", "image"] : ["text"],
|
|
881
|
+
...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
|
|
882
|
+
...(compat === undefined ? {} : { compat }),
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Build one llm-pi-ai route per protocol the selected models need.
|
|
888
|
+
* The base route key (e.g. `cli-proxy`) always carries OpenAI Chat so that
|
|
889
|
+
* existing sessions and the default-model setting keep resolving; Responses
|
|
890
|
+
* and Messages models get `<key>-responses` / `<key>-messages` siblings.
|
|
891
|
+
* @returns `{ allModels, orderedModels, routes }` where `routes` maps each
|
|
892
|
+
* route key to its profile, in protocol order, and every ordered model
|
|
893
|
+
* carries its `protocol` and `route`.
|
|
894
|
+
*/
|
|
895
|
+
export function buildPiAiRoutes(config, discovery, hasApiKey) {
|
|
896
|
+
const allOrdered = sortModelsNewestFirst(discovery.models);
|
|
897
|
+
const filtered = filterModels(allOrdered, config).map((model) => {
|
|
898
|
+
const effective = applyModelOverride(model, config.modelOverrides?.[model.id]);
|
|
899
|
+
const protocol = modelProtocol(model, config);
|
|
900
|
+
return { ...effective, protocol, route: routeKeyFor(config.provider, protocol) };
|
|
901
|
+
});
|
|
902
|
+
if (filtered.length === 0) {
|
|
903
|
+
throw new Error(
|
|
904
|
+
`dsh-cliproxyapi: none of the selected models matched the ${discovery.models.length} model(s) offered by ${discovery.rootURL}. Select models in Settings → Built-in plugins → CLIProxyAPI or with the cliproxyapi tool.`
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
const auth = hasApiKey
|
|
908
|
+
? { apiKeyEnv: config.apiKeyEnv }
|
|
909
|
+
: { headers: { Authorization: "Bearer cpa-local" } };
|
|
910
|
+
const routes = {};
|
|
911
|
+
for (const protocol of PROTOCOLS) {
|
|
912
|
+
const members = filtered.filter((m) => m.protocol === protocol);
|
|
913
|
+
if (members.length === 0) continue;
|
|
914
|
+
routes[routeKeyFor(config.provider, protocol)] = {
|
|
915
|
+
displayName: `${config.displayName}${PROTOCOL_ROUTES[protocol].label}`,
|
|
916
|
+
api: protocol,
|
|
917
|
+
baseURL: routeBaseURL(discovery.rootURL, protocol),
|
|
918
|
+
models: members.map((m) => toPiAiModelProfile(m, protocol)),
|
|
919
|
+
...auth,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
return { allModels: allOrdered, orderedModels: filtered, routes };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Every route key this plugin may own under a provider prefix. */
|
|
926
|
+
export function managedRouteKeys(provider) {
|
|
927
|
+
return PROTOCOLS.map((protocol) => routeKeyFor(provider, protocol));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// #endregion
|
|
931
|
+
|
|
932
|
+
// #region Runtime Synchronizer
|
|
933
|
+
|
|
934
|
+
function sleep(ms) {
|
|
935
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function sameJson(a, b) {
|
|
939
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** Fields this plugin writes; any stale copy is replaced, never merged. */
|
|
943
|
+
const MANAGED_ROUTE_FIELDS = ["displayName", "api", "baseURL", "models", "apiKeyEnv", "headers"];
|
|
944
|
+
|
|
945
|
+
function stripManagedFields(route) {
|
|
946
|
+
const out = { ...route };
|
|
947
|
+
for (const field of MANAGED_ROUTE_FIELDS) delete out[field];
|
|
948
|
+
// An empty dict materialized by the schema is not a user choice.
|
|
949
|
+
if (out.compat !== undefined && sameJson(out.compat, { chatTemplateKwargs: {}, chatTemplateArgs: {} })) {
|
|
950
|
+
delete out.compat;
|
|
951
|
+
}
|
|
952
|
+
return out;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
async function mutateSettingsSafe(settings, ns, ops) {
|
|
956
|
+
let lastError;
|
|
957
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
958
|
+
try {
|
|
959
|
+
await settings.mutate(ns, ops);
|
|
960
|
+
return;
|
|
961
|
+
} catch (error) {
|
|
962
|
+
lastError = error;
|
|
963
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
964
|
+
if (msg.includes("HMR transactions cannot be nested")) {
|
|
965
|
+
await sleep(300 * (attempt + 1));
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
throw error;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
throw lastError;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function formatModelView(m, selectedSet, config) {
|
|
975
|
+
const overrides = config?.modelOverrides ?? {};
|
|
976
|
+
const override = overrides[m.id];
|
|
977
|
+
const effective = applyModelOverride(m, override);
|
|
978
|
+
const protocol = config === undefined ? nativeProtocol(m) : modelProtocol(m, config);
|
|
979
|
+
return {
|
|
980
|
+
protocol,
|
|
981
|
+
nativeProtocol: nativeProtocol(m),
|
|
982
|
+
...(m.ownedBy === undefined ? {} : { ownedBy: m.ownedBy }),
|
|
983
|
+
id: effective.id,
|
|
984
|
+
name: effective.name ?? effective.id,
|
|
985
|
+
family: modelFamily(effective),
|
|
986
|
+
contextWindow: effective.contextWindow ?? 262144,
|
|
987
|
+
maxTokens: effective.maxTokens ?? 64000,
|
|
988
|
+
image: effective.inputModalities.includes("image"),
|
|
989
|
+
reasoning: effective.reasoning,
|
|
990
|
+
reasoningLevels: effective.reasoningLevels,
|
|
991
|
+
selected: selectedSet.has(effective.id),
|
|
992
|
+
...(override === undefined ? {} : { override }),
|
|
993
|
+
discovered: {
|
|
994
|
+
contextWindow: m.contextWindow ?? 262144,
|
|
995
|
+
maxTokens: m.maxTokens ?? 64000,
|
|
996
|
+
image: m.inputModalities.includes("image"),
|
|
997
|
+
reasoningLevels: m.reasoningLevels,
|
|
998
|
+
},
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export class CpaRuntime {
|
|
1003
|
+
constructor(options) {
|
|
1004
|
+
this.options = options;
|
|
1005
|
+
this.activeProvider = undefined;
|
|
1006
|
+
this.activeRoutes = undefined;
|
|
1007
|
+
this.running = undefined;
|
|
1008
|
+
this.rerun = false;
|
|
1009
|
+
this.controller = new AbortController();
|
|
1010
|
+
this.allDiscoveredModels = [];
|
|
1011
|
+
this.syncedModels = [];
|
|
1012
|
+
this.currentStatus = {
|
|
1013
|
+
state: "idle",
|
|
1014
|
+
modelCount: 0,
|
|
1015
|
+
totalDiscovered: 0,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
status() {
|
|
1020
|
+
const syncedSet = new Set(this.syncedModels.map((m) => m.id));
|
|
1021
|
+
const config = this.options.config();
|
|
1022
|
+
const byId = new Map(this.allDiscoveredModels.map((m) => [m.id, m]));
|
|
1023
|
+
return {
|
|
1024
|
+
...this.currentStatus,
|
|
1025
|
+
models: this.syncedModels.map((m) => ({
|
|
1026
|
+
...formatModelView(byId.get(m.id) ?? m, syncedSet, config),
|
|
1027
|
+
route: m.route,
|
|
1028
|
+
})),
|
|
1029
|
+
allModels: this.allDiscoveredModels.map((m) => formatModelView(m, syncedSet, config)),
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Run one sync. `probe: true` also reads the catalog of a disabled account
|
|
1035
|
+
* so its models can be picked before it is enabled; periodic refreshes of a
|
|
1036
|
+
* disabled account stay offline.
|
|
1037
|
+
*/
|
|
1038
|
+
refresh(options = {}) {
|
|
1039
|
+
if (options.probe === true) this.probeRequested = true;
|
|
1040
|
+
if (this.running !== undefined) {
|
|
1041
|
+
this.rerun = true;
|
|
1042
|
+
return this.running;
|
|
1043
|
+
}
|
|
1044
|
+
this.running = this.runLoop().finally(() => {
|
|
1045
|
+
this.running = undefined;
|
|
1046
|
+
});
|
|
1047
|
+
return this.running;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
dispose() {
|
|
1051
|
+
this.controller.abort(new Error("dsh-cliproxyapi disposed"));
|
|
1052
|
+
if (this.hmrRetryTimer !== undefined) clearTimeout(this.hmrRetryTimer);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** Stop syncing and remove every route this account wrote. */
|
|
1056
|
+
async teardown(provider) {
|
|
1057
|
+
this.dispose();
|
|
1058
|
+
try {
|
|
1059
|
+
await this.running;
|
|
1060
|
+
} catch {}
|
|
1061
|
+
await this.releaseRoutes([provider, this.activeProvider]);
|
|
1062
|
+
this.activeProvider = undefined;
|
|
1063
|
+
this.activeRoutes = undefined;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Unset the managed route keys of the given providers that still exist. */
|
|
1067
|
+
async releaseRoutes(providers) {
|
|
1068
|
+
const reserved = this.reservedKeys();
|
|
1069
|
+
const keys = new Set(
|
|
1070
|
+
providers
|
|
1071
|
+
.filter((p) => typeof p === "string" && p !== "")
|
|
1072
|
+
.flatMap((p) => managedRouteKeys(p))
|
|
1073
|
+
);
|
|
1074
|
+
const ops = [];
|
|
1075
|
+
for (const key of keys) {
|
|
1076
|
+
if (reserved.has(key)) continue;
|
|
1077
|
+
if (this.options.readExistingPiAiRoute?.(key) !== undefined) {
|
|
1078
|
+
ops.push({ op: "unset", path: ["providers", key] });
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (ops.length > 0) await mutateSettingsSafe(this.options.settings, PI_AI_NS, ops);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* An account that has never saved a selection gets the catalog's
|
|
1086
|
+
* recommendation once, persisted through `options.seedSelection`, so the
|
|
1087
|
+
* picker starts with one model per family instead of the whole catalog.
|
|
1088
|
+
* @returns the seeded ids, or undefined when a selection already exists.
|
|
1089
|
+
*/
|
|
1090
|
+
seedSelection(config, models) {
|
|
1091
|
+
if (config.selectedModels !== undefined || models.length === 0) return undefined;
|
|
1092
|
+
const ids = recommendModels(models);
|
|
1093
|
+
try {
|
|
1094
|
+
this.options.seedSelection?.(ids);
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
this.options.logger?.warn?.(
|
|
1097
|
+
`dsh-cliproxyapi: could not save the initial model selection: ${error instanceof Error ? error.message : String(error)}`
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
return ids;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** Route keys owned by other accounts, from the latest config. */
|
|
1104
|
+
reservedKeys() {
|
|
1105
|
+
try {
|
|
1106
|
+
const keys = this.options.config?.()?.reservedKeys;
|
|
1107
|
+
return new Set(Array.isArray(keys) ? keys : []);
|
|
1108
|
+
} catch {
|
|
1109
|
+
return new Set();
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** Resolve the key and probe the account's CPA service. */
|
|
1114
|
+
async discover(config) {
|
|
1115
|
+
const existingRoute = this.options.readExistingPiAiRoute?.(config.provider);
|
|
1116
|
+
const effectiveApiKeyEnv =
|
|
1117
|
+
config.apiKeyEnv === DEFAULT_API_KEY_ENV &&
|
|
1118
|
+
existingRoute?.apiKeyEnv &&
|
|
1119
|
+
typeof existingRoute.apiKeyEnv === "string"
|
|
1120
|
+
? existingRoute.apiKeyEnv
|
|
1121
|
+
: config.apiKeyEnv;
|
|
1122
|
+
|
|
1123
|
+
const apiKey = await this.options.resolveApiKey(effectiveApiKeyEnv);
|
|
1124
|
+
const autoDiscover = config.baseURL === "";
|
|
1125
|
+
const fallbackBaseURL =
|
|
1126
|
+
autoDiscover && typeof existingRoute?.baseURL === "string" ? existingRoute.baseURL : undefined;
|
|
1127
|
+
const environmentBaseURL = autoDiscover ? this.options.environmentBaseURL?.() : undefined;
|
|
1128
|
+
|
|
1129
|
+
const result = await discoverCpa({
|
|
1130
|
+
baseURL: config.baseURL,
|
|
1131
|
+
autoDiscover,
|
|
1132
|
+
...(fallbackBaseURL === undefined ? {} : { fallbackBaseURL }),
|
|
1133
|
+
...(environmentBaseURL === undefined ? {} : { environmentBaseURL }),
|
|
1134
|
+
...(apiKey === undefined ? {} : { apiKey }),
|
|
1135
|
+
timeoutMs: config.probeTimeoutMs,
|
|
1136
|
+
signal: this.controller.signal,
|
|
1137
|
+
...(this.options.fetch === undefined ? {} : { fetch: this.options.fetch }),
|
|
1138
|
+
});
|
|
1139
|
+
return { result, apiKey, effectiveApiKeyEnv };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async runLoop() {
|
|
1143
|
+
do {
|
|
1144
|
+
this.rerun = false;
|
|
1145
|
+
try {
|
|
1146
|
+
await this.syncOnce();
|
|
1147
|
+
this.hmrRetries = 0;
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1150
|
+
this.currentStatus = {
|
|
1151
|
+
...this.currentStatus,
|
|
1152
|
+
state: "error",
|
|
1153
|
+
error: message,
|
|
1154
|
+
};
|
|
1155
|
+
this.options.logger?.warn?.(message);
|
|
1156
|
+
this.scheduleHmrRetry(message);
|
|
1157
|
+
}
|
|
1158
|
+
} while (this.rerun && !this.controller.signal.aborted);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* A plugin reload holds DSH's HMR transaction for longer than the in-call
|
|
1163
|
+
* backoff; retry the whole sync a few times once that transaction ends.
|
|
1164
|
+
*/
|
|
1165
|
+
scheduleHmrRetry(message) {
|
|
1166
|
+
if (!message.includes("HMR transactions cannot be nested")) return;
|
|
1167
|
+
this.hmrRetries = (this.hmrRetries ?? 0) + 1;
|
|
1168
|
+
if (this.hmrRetries > 5 || this.controller.signal.aborted) return;
|
|
1169
|
+
if (this.hmrRetryTimer !== undefined) clearTimeout(this.hmrRetryTimer);
|
|
1170
|
+
const schedule = this.options.setTimeout ?? setTimeout;
|
|
1171
|
+
this.hmrRetryTimer = schedule(() => {
|
|
1172
|
+
this.hmrRetryTimer = undefined;
|
|
1173
|
+
void this.refresh();
|
|
1174
|
+
}, 3000 * this.hmrRetries);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
async syncOnce() {
|
|
1178
|
+
const config = this.options.config();
|
|
1179
|
+
const probe = this.probeRequested === true;
|
|
1180
|
+
this.probeRequested = false;
|
|
1181
|
+
|
|
1182
|
+
// An account whose route keys belong to another account must not write
|
|
1183
|
+
// or remove them.
|
|
1184
|
+
if (config.conflictWith !== undefined) {
|
|
1185
|
+
this.syncedModels = [];
|
|
1186
|
+
throw new Error(
|
|
1187
|
+
`dsh-cliproxyapi: provider route "${config.provider}" overlaps account "${config.conflictWith}"; choose a different Provider Route ID`
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (!config.enabled) {
|
|
1192
|
+
// A disabled account keeps no models in the picker.
|
|
1193
|
+
await this.releaseRoutes([config.provider, this.activeProvider]);
|
|
1194
|
+
this.activeProvider = undefined;
|
|
1195
|
+
this.activeRoutes = undefined;
|
|
1196
|
+
this.syncedModels = [];
|
|
1197
|
+
let endpoint;
|
|
1198
|
+
let probeError;
|
|
1199
|
+
if (probe) {
|
|
1200
|
+
try {
|
|
1201
|
+
const { result } = await this.discover(config);
|
|
1202
|
+
this.allDiscoveredModels = result.models;
|
|
1203
|
+
this.seedSelection(config, result.models);
|
|
1204
|
+
endpoint = result.rootURL;
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
if (this.controller.signal.aborted) throw error;
|
|
1207
|
+
probeError = error instanceof Error ? error.message : String(error);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
this.currentStatus = {
|
|
1211
|
+
state: "disabled",
|
|
1212
|
+
provider: config.provider,
|
|
1213
|
+
displayName: config.displayName,
|
|
1214
|
+
...(endpoint === undefined ? {} : { endpoint }),
|
|
1215
|
+
modelCount: 0,
|
|
1216
|
+
totalDiscovered: this.allDiscoveredModels.length,
|
|
1217
|
+
...(probe ? { refreshedAt: new Date().toISOString() } : {}),
|
|
1218
|
+
...(probeError === undefined ? {} : { error: probeError }),
|
|
1219
|
+
};
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const { error: _prevErr, ...prevStatus } = this.currentStatus;
|
|
1224
|
+
this.currentStatus = { ...prevStatus, state: "probing" };
|
|
1225
|
+
|
|
1226
|
+
const { result, apiKey, effectiveApiKeyEnv } = await this.discover(config);
|
|
1227
|
+
// Keep the catalog visible in the panel even if the selection matches nothing.
|
|
1228
|
+
this.allDiscoveredModels = result.models;
|
|
1229
|
+
const seeded = this.seedSelection(config, result.models);
|
|
1230
|
+
|
|
1231
|
+
const effectiveConfig = {
|
|
1232
|
+
...config,
|
|
1233
|
+
...(seeded === undefined ? {} : { selectedModels: seeded }),
|
|
1234
|
+
apiKeyEnv: effectiveApiKeyEnv,
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
const built = buildPiAiRoutes(effectiveConfig, result, apiKey !== undefined);
|
|
1238
|
+
this.syncedModels = built.orderedModels;
|
|
1239
|
+
|
|
1240
|
+
// Each managed route keeps unrelated fields a user set by hand (retry
|
|
1241
|
+
// policy, image budgets) and takes this plugin's fields on top. A route
|
|
1242
|
+
// key that no longer carries models is removed.
|
|
1243
|
+
const reserved = new Set(config.reservedKeys ?? []);
|
|
1244
|
+
const ownedKeys = new Set(
|
|
1245
|
+
[
|
|
1246
|
+
...managedRouteKeys(config.provider),
|
|
1247
|
+
...(this.activeProvider !== undefined && this.activeProvider !== config.provider
|
|
1248
|
+
? managedRouteKeys(this.activeProvider)
|
|
1249
|
+
: []),
|
|
1250
|
+
].filter((key) => !reserved.has(key))
|
|
1251
|
+
);
|
|
1252
|
+
const ops = [];
|
|
1253
|
+
const nextRoutes = {};
|
|
1254
|
+
for (const key of ownedKeys) {
|
|
1255
|
+
const existing = this.options.readExistingPiAiRoute?.(key);
|
|
1256
|
+
const wanted = built.routes[key];
|
|
1257
|
+
if (wanted === undefined) {
|
|
1258
|
+
if (existing !== undefined) ops.push({ op: "unset", path: ["providers", key] });
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
const merged = {
|
|
1262
|
+
...(existing && typeof existing === "object" ? stripManagedFields(existing) : {}),
|
|
1263
|
+
...wanted,
|
|
1264
|
+
};
|
|
1265
|
+
nextRoutes[key] = merged;
|
|
1266
|
+
if (!sameJson(existing, merged)) {
|
|
1267
|
+
ops.push({ op: "set", path: ["providers", key], value: merged });
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (ops.length > 0) {
|
|
1271
|
+
await mutateSettingsSafe(this.options.settings, PI_AI_NS, ops);
|
|
1272
|
+
}
|
|
1273
|
+
this.activeProvider = config.provider;
|
|
1274
|
+
this.activeRoutes = nextRoutes;
|
|
1275
|
+
await this.followDefaultModel(built.orderedModels, ownedKeys);
|
|
1276
|
+
|
|
1277
|
+
const protocolCounts = {};
|
|
1278
|
+
for (const m of built.orderedModels) protocolCounts[m.protocol] = (protocolCounts[m.protocol] ?? 0) + 1;
|
|
1279
|
+
this.currentStatus = {
|
|
1280
|
+
state: "connected",
|
|
1281
|
+
endpoint: result.rootURL,
|
|
1282
|
+
provider: config.provider,
|
|
1283
|
+
displayName: config.displayName,
|
|
1284
|
+
routes: Object.keys(built.routes),
|
|
1285
|
+
protocolCounts,
|
|
1286
|
+
modelCount: built.orderedModels.length,
|
|
1287
|
+
totalDiscovered: result.models.length,
|
|
1288
|
+
refreshedAt: new Date().toISOString(),
|
|
1289
|
+
};
|
|
1290
|
+
this.options.logger?.info?.(
|
|
1291
|
+
`dsh-cliproxyapi: connected to ${result.rootURL}; synchronized ${built.orderedModels.length}/${result.models.length} model(s) into ${Object.keys(built.routes).join(", ")}`
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Keep the agent default model reachable when protocol routing moves it to
|
|
1297
|
+
* a sibling route (e.g. `cli-proxy` -> `cli-proxy-messages`).
|
|
1298
|
+
*/
|
|
1299
|
+
async followDefaultModel(models, ownedKeys) {
|
|
1300
|
+
const current = this.options.readDefaultModel?.();
|
|
1301
|
+
if (current === undefined || !ownedKeys.has(current.provider)) return;
|
|
1302
|
+
const target = models.find((m) => m.id === current.model);
|
|
1303
|
+
if (target === undefined || target.route === current.provider) return;
|
|
1304
|
+
try {
|
|
1305
|
+
await mutateSettingsSafe(this.options.settings, DEFAULT_MODEL_NS, [
|
|
1306
|
+
{ op: "set", path: ["provider"], value: target.route },
|
|
1307
|
+
]);
|
|
1308
|
+
} catch (error) {
|
|
1309
|
+
this.options.logger?.warn?.(
|
|
1310
|
+
`dsh-cliproxyapi: could not move default model to ${target.route}: ${error instanceof Error ? error.message : String(error)}`
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* Queue settings writes so concurrent account syncs never interleave their
|
|
1318
|
+
* transactions on the shared `llm-pi-ai` namespace.
|
|
1319
|
+
*/
|
|
1320
|
+
export function serializeSettings(settings) {
|
|
1321
|
+
let tail = Promise.resolve();
|
|
1322
|
+
return {
|
|
1323
|
+
mutate(ns, ops) {
|
|
1324
|
+
const run = tail.then(() => settings.mutate(ns, ops));
|
|
1325
|
+
tail = run.catch(() => {});
|
|
1326
|
+
return run;
|
|
1327
|
+
},
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Owns one {@link CpaRuntime} per account and the state file they share.
|
|
1333
|
+
*
|
|
1334
|
+
* @param deps.readState - returns the normalized state (see {@link normalizeState}).
|
|
1335
|
+
* @param deps.writeState - persists a normalized state.
|
|
1336
|
+
* @param deps.resolveApiKey / deps.describeApiKey - credential lookups by ref.
|
|
1337
|
+
* @param deps.setCredential / deps.unsetCredential - credential writes by ref.
|
|
1338
|
+
* @param deps.onGlobalsChanged - called after the refresh interval changes.
|
|
1339
|
+
*/
|
|
1340
|
+
export function createCpaController(deps) {
|
|
1341
|
+
const settings = serializeSettings(deps.settings);
|
|
1342
|
+
const runtimes = new Map();
|
|
1343
|
+
|
|
1344
|
+
const accountConfig = (state, id) => {
|
|
1345
|
+
const account = state.accounts.find((a) => a.id === id);
|
|
1346
|
+
if (account === undefined) return undefined;
|
|
1347
|
+
const conflicts = accountConflicts(state.accounts);
|
|
1348
|
+
const conflictWith = conflicts.get(id);
|
|
1349
|
+
return {
|
|
1350
|
+
...resolveConfig({
|
|
1351
|
+
...account,
|
|
1352
|
+
refreshIntervalSeconds: state.refreshIntervalSeconds,
|
|
1353
|
+
probeTimeoutMs: state.probeTimeoutMs,
|
|
1354
|
+
}),
|
|
1355
|
+
// Route keys of every other (non-conflicting) account: never written or
|
|
1356
|
+
// removed here. A conflicting account owns no keys.
|
|
1357
|
+
reservedKeys: state.accounts
|
|
1358
|
+
.filter((a) => a.id !== id && !conflicts.has(a.id))
|
|
1359
|
+
.flatMap((a) => managedRouteKeys(a.provider)),
|
|
1360
|
+
...(conflictWith === undefined ? {} : { conflictWith }),
|
|
1361
|
+
};
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
const ensureRuntime = (id) => {
|
|
1365
|
+
const existing = runtimes.get(id);
|
|
1366
|
+
if (existing !== undefined) return existing;
|
|
1367
|
+
let last = accountConfig(deps.readState(), id) ?? resolveConfig({ id, enabled: false });
|
|
1368
|
+
const runtime = new CpaRuntime({
|
|
1369
|
+
settings,
|
|
1370
|
+
config: () => {
|
|
1371
|
+
const config = accountConfig(deps.readState(), id);
|
|
1372
|
+
if (config !== undefined) last = config;
|
|
1373
|
+
return last;
|
|
1374
|
+
},
|
|
1375
|
+
resolveApiKey: deps.resolveApiKey,
|
|
1376
|
+
readExistingPiAiRoute: deps.readExistingPiAiRoute,
|
|
1377
|
+
readDefaultModel: deps.readDefaultModel,
|
|
1378
|
+
// Persist a first-run selection unless one was saved in the meantime.
|
|
1379
|
+
seedSelection: (ids) => {
|
|
1380
|
+
const state = deps.readState();
|
|
1381
|
+
const account = state.accounts.find((a) => a.id === id);
|
|
1382
|
+
if (account === undefined || account.selectedModels !== undefined) return;
|
|
1383
|
+
deps.writeState(
|
|
1384
|
+
normalizeState({
|
|
1385
|
+
...state,
|
|
1386
|
+
accounts: state.accounts.map((a) => (a.id === id ? { ...a, selectedModels: ids } : a)),
|
|
1387
|
+
})
|
|
1388
|
+
);
|
|
1389
|
+
},
|
|
1390
|
+
...(deps.setTimeout === undefined ? {} : { setTimeout: deps.setTimeout }),
|
|
1391
|
+
...(deps.environmentBaseURL === undefined ? {} : { environmentBaseURL: deps.environmentBaseURL }),
|
|
1392
|
+
...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
|
|
1393
|
+
logger: deps.logger,
|
|
1394
|
+
});
|
|
1395
|
+
runtime.lastProvider = () => last.provider;
|
|
1396
|
+
runtimes.set(id, runtime);
|
|
1397
|
+
return runtime;
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
/** Create runtimes for new accounts and tear down removed ones. */
|
|
1401
|
+
const reconcile = async (state) => {
|
|
1402
|
+
for (const account of state.accounts) ensureRuntime(account.id);
|
|
1403
|
+
const live = new Set(state.accounts.map((a) => a.id));
|
|
1404
|
+
const removed = [...runtimes.entries()].filter(([id]) => !live.has(id));
|
|
1405
|
+
for (const [id, runtime] of removed) {
|
|
1406
|
+
runtimes.delete(id);
|
|
1407
|
+
try {
|
|
1408
|
+
await runtime.teardown(runtime.lastProvider());
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
deps.logger?.warn?.(
|
|
1411
|
+
`dsh-cliproxyapi: could not remove routes of account ${id}: ${error instanceof Error ? error.message : String(error)}`
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
const findAccount = (state, ref) => {
|
|
1418
|
+
if (ref === undefined || ref === null || String(ref).trim() === "") {
|
|
1419
|
+
if (state.accounts.length === 0) {
|
|
1420
|
+
throw new Error("dsh-cliproxyapi: no CLIProxyAPI account is configured; add one first");
|
|
1421
|
+
}
|
|
1422
|
+
return state.accounts[0];
|
|
1423
|
+
}
|
|
1424
|
+
const wanted = String(ref).trim();
|
|
1425
|
+
const hit =
|
|
1426
|
+
state.accounts.find((a) => a.id === wanted) ??
|
|
1427
|
+
state.accounts.find((a) => a.provider === wanted) ??
|
|
1428
|
+
state.accounts.find((a) => a.displayName === wanted);
|
|
1429
|
+
if (hit === undefined) {
|
|
1430
|
+
throw new Error(
|
|
1431
|
+
`dsh-cliproxyapi: unknown account "${wanted}" (known: ${state.accounts.map((a) => a.id).join(", ") || "none"})`
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
return hit;
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
const effectiveKeyEnv = (config) => {
|
|
1438
|
+
const route = deps.readExistingPiAiRoute?.(config.provider);
|
|
1439
|
+
return config.apiKeyEnv === DEFAULT_API_KEY_ENV && typeof route?.apiKeyEnv === "string"
|
|
1440
|
+
? route.apiKeyEnv
|
|
1441
|
+
: config.apiKeyEnv;
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1444
|
+
const describeAccount = async (state, account) => {
|
|
1445
|
+
const config = accountConfig(state, account.id);
|
|
1446
|
+
const runtime = ensureRuntime(account.id);
|
|
1447
|
+
const st = runtime.status();
|
|
1448
|
+
const existingRoute = deps.readExistingPiAiRoute?.(config.provider);
|
|
1449
|
+
const keyEnv = effectiveKeyEnv(config);
|
|
1450
|
+
const keyInfo = (await deps.describeApiKey?.(keyEnv)) ?? { configured: false };
|
|
1451
|
+
return {
|
|
1452
|
+
id: account.id,
|
|
1453
|
+
config: {
|
|
1454
|
+
...config,
|
|
1455
|
+
selectedModels: config.selectedModels ?? [],
|
|
1456
|
+
// false until a selection is saved or seeded by the first probe.
|
|
1457
|
+
selectionSaved: config.selectedModels !== undefined,
|
|
1458
|
+
modelOverrides: config.modelOverrides ?? {},
|
|
1459
|
+
effectiveBaseURL: config.baseURL || st.endpoint || existingRoute?.baseURL || "",
|
|
1460
|
+
effectiveApiKeyEnv: keyEnv,
|
|
1461
|
+
},
|
|
1462
|
+
credential: {
|
|
1463
|
+
ref: keyEnv,
|
|
1464
|
+
configured: keyInfo.configured === true,
|
|
1465
|
+
writable: keyInfo.writable !== false,
|
|
1466
|
+
source: keyInfo.source ?? null,
|
|
1467
|
+
},
|
|
1468
|
+
// One model per family from this account's catalog (the panel's reset button).
|
|
1469
|
+
recommendedModels: recommendModels(runtime.allDiscoveredModels),
|
|
1470
|
+
status: st,
|
|
1471
|
+
};
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
const statusPayload = async (extra = {}) => {
|
|
1475
|
+
const state = deps.readState();
|
|
1476
|
+
const accounts = [];
|
|
1477
|
+
for (const account of state.accounts) accounts.push(await describeAccount(state, account));
|
|
1478
|
+
return {
|
|
1479
|
+
ok: true,
|
|
1480
|
+
global: {
|
|
1481
|
+
refreshIntervalSeconds: state.refreshIntervalSeconds,
|
|
1482
|
+
probeTimeoutMs: state.probeTimeoutMs,
|
|
1483
|
+
},
|
|
1484
|
+
accounts,
|
|
1485
|
+
...extra,
|
|
1486
|
+
};
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
const applyKeyPatch = async (account, patch) => {
|
|
1490
|
+
const ref = toCredentialRef(account.apiKeyEnv);
|
|
1491
|
+
if (typeof patch.apiKey === "string" && patch.apiKey.trim() !== "") {
|
|
1492
|
+
if (deps.setCredential === undefined) {
|
|
1493
|
+
throw new Error("Credentials service is not available on this host");
|
|
1494
|
+
}
|
|
1495
|
+
await deps.setCredential(ref, patch.apiKey.trim());
|
|
1496
|
+
} else if (patch.clearApiKey === true) {
|
|
1497
|
+
await deps.unsetCredential?.(ref);
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
const writeAccounts = (state, accounts, patch = {}) => {
|
|
1502
|
+
assertNoConflicts(accounts);
|
|
1503
|
+
for (const account of accounts) toCredentialRef(account.apiKeyEnv);
|
|
1504
|
+
const next = normalizeState({ ...state, ...pickFields(patch, GLOBAL_FIELDS), accounts });
|
|
1505
|
+
deps.writeState(next);
|
|
1506
|
+
if (next.refreshIntervalSeconds !== state.refreshIntervalSeconds) deps.onGlobalsChanged?.(next);
|
|
1507
|
+
return next;
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
const refreshAccount = (id, options) => ensureRuntime(id).refresh(options);
|
|
1511
|
+
|
|
1512
|
+
return {
|
|
1513
|
+
statusPayload,
|
|
1514
|
+
findAccount: (ref) => findAccount(deps.readState(), ref),
|
|
1515
|
+
|
|
1516
|
+
/** Refresh every account (no catalog probe for disabled ones). */
|
|
1517
|
+
async refreshAll(options = {}) {
|
|
1518
|
+
const state = deps.readState();
|
|
1519
|
+
await reconcile(state);
|
|
1520
|
+
await Promise.all(state.accounts.map((a) => refreshAccount(a.id, options)));
|
|
1521
|
+
},
|
|
1522
|
+
|
|
1523
|
+
/** Refresh one account, or all of them when `ref` is omitted. */
|
|
1524
|
+
async refresh(ref, options = { probe: true }) {
|
|
1525
|
+
const state = deps.readState();
|
|
1526
|
+
await reconcile(state);
|
|
1527
|
+
if (ref === undefined || ref === null || String(ref).trim() === "") {
|
|
1528
|
+
await Promise.all(state.accounts.map((a) => refreshAccount(a.id, options)));
|
|
1529
|
+
return statusPayload();
|
|
1530
|
+
}
|
|
1531
|
+
const account = findAccount(state, ref);
|
|
1532
|
+
await refreshAccount(account.id, options);
|
|
1533
|
+
return statusPayload({ account: account.id });
|
|
1534
|
+
},
|
|
1535
|
+
|
|
1536
|
+
/** Update one account's settings (and globals in the same patch). */
|
|
1537
|
+
async updateAccount(ref, patch = {}) {
|
|
1538
|
+
const state = deps.readState();
|
|
1539
|
+
const account = findAccount(state, ref);
|
|
1540
|
+
const updated = patchAccount(account, patch);
|
|
1541
|
+
const accounts = state.accounts.map((a) => (a.id === account.id ? updated : a));
|
|
1542
|
+
writeAccounts(state, accounts, patch);
|
|
1543
|
+
await applyKeyPatch(updated, patch);
|
|
1544
|
+
await refreshAccount(account.id, { probe: true });
|
|
1545
|
+
return statusPayload({ account: account.id });
|
|
1546
|
+
},
|
|
1547
|
+
|
|
1548
|
+
/** Update only the global settings. */
|
|
1549
|
+
async updateGlobals(patch = {}) {
|
|
1550
|
+
const state = deps.readState();
|
|
1551
|
+
writeAccounts(state, state.accounts, patch);
|
|
1552
|
+
return statusPayload();
|
|
1553
|
+
},
|
|
1554
|
+
|
|
1555
|
+
/** Add an account; it starts disabled unless `patch.enabled` is true. */
|
|
1556
|
+
async addAccount(patch = {}) {
|
|
1557
|
+
const state = deps.readState();
|
|
1558
|
+
const defaults = newAccountDefaults(state.accounts);
|
|
1559
|
+
const created = patchAccount(defaults, {
|
|
1560
|
+
...patch,
|
|
1561
|
+
enabled: patch.enabled === true,
|
|
1562
|
+
});
|
|
1563
|
+
const accounts = [...state.accounts, created];
|
|
1564
|
+
const next = writeAccounts(state, accounts, patch);
|
|
1565
|
+
await applyKeyPatch(created, patch);
|
|
1566
|
+
await reconcile(next);
|
|
1567
|
+
await refreshAccount(created.id, { probe: true });
|
|
1568
|
+
return statusPayload({ account: created.id, createdId: created.id });
|
|
1569
|
+
},
|
|
1570
|
+
|
|
1571
|
+
/** Remove an account and every route it synchronized. */
|
|
1572
|
+
async removeAccount(ref) {
|
|
1573
|
+
if (ref === undefined || ref === null || String(ref).trim() === "") {
|
|
1574
|
+
throw new Error("dsh-cliproxyapi: removing an account needs its id");
|
|
1575
|
+
}
|
|
1576
|
+
const state = deps.readState();
|
|
1577
|
+
const account = findAccount(state, ref);
|
|
1578
|
+
// Capture the account's provider (and reserved keys) before it leaves the state.
|
|
1579
|
+
ensureRuntime(account.id).options.config();
|
|
1580
|
+
const next = writeAccounts(
|
|
1581
|
+
state,
|
|
1582
|
+
state.accounts.filter((a) => a.id !== account.id)
|
|
1583
|
+
);
|
|
1584
|
+
await reconcile(next);
|
|
1585
|
+
return statusPayload({ removedId: account.id });
|
|
1586
|
+
},
|
|
1587
|
+
|
|
1588
|
+
/** Accounts whose credential reference is `ref`. */
|
|
1589
|
+
accountsUsingKey(ref) {
|
|
1590
|
+
const state = deps.readState();
|
|
1591
|
+
return state.accounts
|
|
1592
|
+
.map((a) => accountConfig(state, a.id))
|
|
1593
|
+
.filter((config) => effectiveKeyEnv(config) === ref)
|
|
1594
|
+
.map((config) => config.id);
|
|
1595
|
+
},
|
|
1596
|
+
|
|
1597
|
+
refreshAccount,
|
|
1598
|
+
|
|
1599
|
+
dispose() {
|
|
1600
|
+
for (const runtime of runtimes.values()) runtime.dispose();
|
|
1601
|
+
runtimes.clear();
|
|
1602
|
+
},
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// #endregion
|
|
1607
|
+
|
|
1608
|
+
// #region HTTP Gate & WebServer Routes
|
|
1609
|
+
|
|
1610
|
+
export const ROUTES = {
|
|
1611
|
+
status: "/api/dsh-cliproxyapi/status",
|
|
1612
|
+
refresh: "/api/dsh-cliproxyapi/refresh",
|
|
1613
|
+
save: "/api/dsh-cliproxyapi/save",
|
|
1614
|
+
addAccount: "/api/dsh-cliproxyapi/accounts/add",
|
|
1615
|
+
removeAccount: "/api/dsh-cliproxyapi/accounts/remove",
|
|
1616
|
+
};
|
|
1617
|
+
|
|
1618
|
+
function sendJson(res, status, payload) {
|
|
1619
|
+
res.writeHead(status, {
|
|
1620
|
+
"cache-control": "no-store",
|
|
1621
|
+
"content-type": "application/json; charset=utf-8",
|
|
1622
|
+
});
|
|
1623
|
+
res.end(JSON.stringify(payload));
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function loopbackAuthority(host) {
|
|
1627
|
+
if (typeof host !== "string") return false;
|
|
1628
|
+
const lower = host.toLowerCase();
|
|
1629
|
+
const name = lower.startsWith("[")
|
|
1630
|
+
? lower.slice(0, lower.indexOf("]") + 1)
|
|
1631
|
+
: lower.split(":")[0];
|
|
1632
|
+
return (
|
|
1633
|
+
name === "127.0.0.1" ||
|
|
1634
|
+
name === "localhost" ||
|
|
1635
|
+
name === "[::1]" ||
|
|
1636
|
+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function admitRequest(ctx, req, res, expectedMethod) {
|
|
1641
|
+
if (req.method !== expectedMethod) {
|
|
1642
|
+
sendJson(res, 405, { error: "Method Not Allowed" });
|
|
1643
|
+
return false;
|
|
1644
|
+
}
|
|
1645
|
+
const connection = ctx.get?.("connection");
|
|
1646
|
+
if (typeof connection?.requestRejection === "function") {
|
|
1647
|
+
let rejection;
|
|
1648
|
+
try {
|
|
1649
|
+
rejection = connection.requestRejection(req);
|
|
1650
|
+
} catch {
|
|
1651
|
+
rejection = 403;
|
|
1652
|
+
}
|
|
1653
|
+
if (rejection !== undefined) {
|
|
1654
|
+
const code = typeof rejection === "number" ? rejection : rejection?.status ?? 403;
|
|
1655
|
+
sendJson(res, code, { error: "Request rejected by host connection security policy" });
|
|
1656
|
+
return false;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
if (expectedMethod === "POST") {
|
|
1660
|
+
const host = req.headers.host;
|
|
1661
|
+
const trusted = Array.isArray(connection?.trustedHosts) ? connection.trustedHosts : [];
|
|
1662
|
+
if (
|
|
1663
|
+
host !== undefined &&
|
|
1664
|
+
!loopbackAuthority(host) &&
|
|
1665
|
+
!trusted.some((t) => String(t).toLowerCase() === host.toLowerCase())
|
|
1666
|
+
) {
|
|
1667
|
+
sendJson(res, 403, { error: "Forbidden host" });
|
|
1668
|
+
return false;
|
|
1669
|
+
}
|
|
1670
|
+
if (req.headers["sec-fetch-site"] === "cross-site") {
|
|
1671
|
+
sendJson(res, 403, { error: "Cross-site request forbidden" });
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
const origin = req.headers.origin;
|
|
1675
|
+
if (origin !== undefined) {
|
|
1676
|
+
try {
|
|
1677
|
+
if (new URL(origin).host !== host) {
|
|
1678
|
+
sendJson(res, 403, { error: "Origin mismatch" });
|
|
1679
|
+
return false;
|
|
1680
|
+
}
|
|
1681
|
+
} catch {
|
|
1682
|
+
sendJson(res, 403, { error: "Invalid Origin" });
|
|
1683
|
+
return false;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return true;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
async function readJsonBody(req, maxBytes = 256 * 1024) {
|
|
1691
|
+
const chunks = [];
|
|
1692
|
+
let size = 0;
|
|
1693
|
+
for await (const chunk of req) {
|
|
1694
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1695
|
+
size += buf.length;
|
|
1696
|
+
if (size > maxBytes) throw new Error("Request body too large");
|
|
1697
|
+
chunks.push(buf);
|
|
1698
|
+
}
|
|
1699
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1700
|
+
return raw.trim() === "" ? {} : JSON.parse(raw);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// #endregion
|
|
1704
|
+
|
|
1705
|
+
// #region Mount Once Guard
|
|
1706
|
+
|
|
1707
|
+
const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
|
|
1708
|
+
const WAITERS = Symbol.for("dsh-web.mounted-plugins.waiters");
|
|
1709
|
+
|
|
1710
|
+
function mountedSet() {
|
|
1711
|
+
const existing = globalThis[MOUNTED];
|
|
1712
|
+
if (existing instanceof Set) return existing;
|
|
1713
|
+
const created = new Set();
|
|
1714
|
+
globalThis[MOUNTED] = created;
|
|
1715
|
+
return created;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
function mountWaiters() {
|
|
1719
|
+
return (globalThis[WAITERS] ??= new Map());
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function mountOnce(packageName, fn) {
|
|
1723
|
+
const mount = (...args) => {
|
|
1724
|
+
const mounted = mountedSet();
|
|
1725
|
+
const ctx = args[0];
|
|
1726
|
+
if (mounted.has(packageName)) {
|
|
1727
|
+
const waiters = mountWaiters();
|
|
1728
|
+
const queue = waiters.get(packageName) ?? [];
|
|
1729
|
+
let alive = true;
|
|
1730
|
+
const pending = {
|
|
1731
|
+
run: () => {
|
|
1732
|
+
if (alive) mount(...args);
|
|
1733
|
+
},
|
|
1734
|
+
};
|
|
1735
|
+
ctx?.effect?.(() => () => {
|
|
1736
|
+
alive = false;
|
|
1737
|
+
const idx = queue.indexOf(pending);
|
|
1738
|
+
if (idx >= 0) queue.splice(idx, 1);
|
|
1739
|
+
});
|
|
1740
|
+
queue.push(pending);
|
|
1741
|
+
waiters.set(packageName, queue);
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
mounted.add(packageName);
|
|
1745
|
+
ctx?.effect?.(() => () => {
|
|
1746
|
+
mounted.delete(packageName);
|
|
1747
|
+
const waiters = mountWaiters();
|
|
1748
|
+
const queue = waiters.get(packageName);
|
|
1749
|
+
if (queue === undefined) return;
|
|
1750
|
+
waiters.delete(packageName);
|
|
1751
|
+
for (const waiter of queue.splice(0)) {
|
|
1752
|
+
queueMicrotask(() => waiter.run());
|
|
1753
|
+
}
|
|
1754
|
+
});
|
|
1755
|
+
return fn(...args);
|
|
1756
|
+
};
|
|
1757
|
+
return mount;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// #endregion
|
|
1761
|
+
|
|
1762
|
+
// #region Plugin Apply Implementation
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* Run `fn` outside the HMR transaction's async context.
|
|
1766
|
+
*
|
|
1767
|
+
* dsh-hmr marks a running transaction with an AsyncLocalStorage store, and
|
|
1768
|
+
* this plugin's apply() runs inside the install/enable transaction. Timers
|
|
1769
|
+
* created there inherit that store forever, so every later settings write
|
|
1770
|
+
* from them fails with "HMR transactions cannot be nested". Creating the
|
|
1771
|
+
* timers under `executing.exit()` — what dsh-hmr does for its own watchers —
|
|
1772
|
+
* gives them a clean context.
|
|
1773
|
+
*/
|
|
1774
|
+
function outsideHmrTransaction(ctx, fn) {
|
|
1775
|
+
try {
|
|
1776
|
+
const executing = ctx.get?.("hmr")?.executing;
|
|
1777
|
+
if (typeof executing?.exit === "function") return executing.exit(fn);
|
|
1778
|
+
} catch {}
|
|
1779
|
+
return fn();
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function renderAccountStatus(account) {
|
|
1783
|
+
const { config: cfg, credential: keyInfo, status } = account;
|
|
1784
|
+
const selectedMode =
|
|
1785
|
+
cfg.selectionSaved === false
|
|
1786
|
+
? "Selected Filter: not chosen yet (the first successful probe checks the newest model of each family)"
|
|
1787
|
+
: Array.isArray(cfg.selectedModels) && cfg.selectedModels.length > 0
|
|
1788
|
+
? `Selected Filter: ${cfg.selectedModels.length} model(s) checked`
|
|
1789
|
+
: "Selected Filter: All models (no selection filter active)";
|
|
1790
|
+
const modelListPreview =
|
|
1791
|
+
Array.isArray(status.models) && status.models.length > 0
|
|
1792
|
+
? `Active Models in Picker: ${status.models
|
|
1793
|
+
.slice(0, 12)
|
|
1794
|
+
.map((m) => m.id)
|
|
1795
|
+
.join(", ")}${status.models.length > 12 ? ` ... (+${status.models.length - 12} more)` : ""}`
|
|
1796
|
+
: undefined;
|
|
1797
|
+
const counts = status.protocolCounts ?? {};
|
|
1798
|
+
const protocolLine =
|
|
1799
|
+
cfg.protocolMode === "fixed"
|
|
1800
|
+
? `Protocol: ${cfg.protocol} (fixed for every model)`
|
|
1801
|
+
: `Protocol: native per model (${PROTOCOLS.filter((p) => counts[p])
|
|
1802
|
+
.map((p) => `${p}: ${counts[p]}`)
|
|
1803
|
+
.join(", ") || "none synced yet"})`;
|
|
1804
|
+
const lines = [
|
|
1805
|
+
`Account "${cfg.displayName}" [id: ${account.id}] — ${cfg.enabled ? "enabled" : "disabled"}`,
|
|
1806
|
+
`CLIProxyAPI Status: ${status.state}`,
|
|
1807
|
+
`Provider Routes: ${(status.state === "connected" && status.routes ? status.routes : [cfg.provider]).join(", ")}`,
|
|
1808
|
+
protocolLine,
|
|
1809
|
+
`Configured Base URL: ${cfg.baseURL || "(auto-discover / inherited)"}`,
|
|
1810
|
+
...(status.endpoint ? [`Active Endpoint: ${status.endpoint}`] : []),
|
|
1811
|
+
`API Key Reference: ${keyInfo.ref} (${keyInfo.configured ? "configured" : "not set"})`,
|
|
1812
|
+
selectedMode,
|
|
1813
|
+
`Synchronized Models: ${status.modelCount}${
|
|
1814
|
+
status.totalDiscovered ? ` (of ${status.totalDiscovered} discovered)` : ""
|
|
1815
|
+
}`,
|
|
1816
|
+
...(modelListPreview ? [modelListPreview] : []),
|
|
1817
|
+
...(status.refreshedAt ? [`Last Refreshed: ${status.refreshedAt}`] : []),
|
|
1818
|
+
...(status.error ? [`Error: ${status.error}`] : []),
|
|
1819
|
+
];
|
|
1820
|
+
return lines.join("\n");
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
export function renderStatusText(payload, onlyAccountId) {
|
|
1824
|
+
const accounts = payload.accounts ?? [];
|
|
1825
|
+
if (accounts.length === 0) {
|
|
1826
|
+
return "No CLIProxyAPI account is configured. Add one with the cliproxyapi tool (action 'add_account') or in Settings → Built-in plugins → CLIProxyAPI.";
|
|
1827
|
+
}
|
|
1828
|
+
const shown =
|
|
1829
|
+
onlyAccountId === undefined ? accounts : accounts.filter((a) => a.id === onlyAccountId);
|
|
1830
|
+
const enabled = accounts.filter((a) => a.config.enabled).length;
|
|
1831
|
+
const header = `CLIProxyAPI accounts: ${accounts.length} (${enabled} enabled); auto refresh every ${payload.global?.refreshIntervalSeconds ?? DEFAULT_REFRESH_INTERVAL_SECONDS}s`;
|
|
1832
|
+
return [header, ...shown.map(renderAccountStatus)].join("\n\n");
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function summarizeAccount(account) {
|
|
1836
|
+
const st = account.status;
|
|
1837
|
+
return {
|
|
1838
|
+
id: account.id,
|
|
1839
|
+
displayName: account.config.displayName,
|
|
1840
|
+
provider: account.config.provider,
|
|
1841
|
+
enabled: account.config.enabled,
|
|
1842
|
+
state: st.state,
|
|
1843
|
+
...(st.endpoint ? { endpoint: st.endpoint } : {}),
|
|
1844
|
+
modelCount: st.modelCount ?? 0,
|
|
1845
|
+
totalDiscovered: st.totalDiscovered ?? 0,
|
|
1846
|
+
...(st.error ? { error: st.error } : {}),
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/** Account fields the tool and the save route accept, besides key handling. */
|
|
1851
|
+
function accountPatchFromInput(input) {
|
|
1852
|
+
const patch = {};
|
|
1853
|
+
for (const field of ["baseURL", "provider", "displayName", "protocol", "protocolMode", "includePrefix", "excludePrefix", "apiKeyEnv"]) {
|
|
1854
|
+
if (typeof input[field] === "string") patch[field] = input[field];
|
|
1855
|
+
}
|
|
1856
|
+
if (typeof input.enabled === "boolean") patch.enabled = input.enabled;
|
|
1857
|
+
if (Array.isArray(input.selectedModels)) patch.selectedModels = input.selectedModels;
|
|
1858
|
+
if (plainObject(input.modelOverrides) !== undefined) patch.modelOverrides = input.modelOverrides;
|
|
1859
|
+
if (typeof input.apiKey === "string" && input.apiKey.trim() !== "") patch.apiKey = input.apiKey.trim();
|
|
1860
|
+
if (input.clearApiKey === true) patch.clearApiKey = true;
|
|
1861
|
+
for (const field of GLOBAL_FIELDS) {
|
|
1862
|
+
if (input[field] !== undefined) patch[field] = input[field];
|
|
1863
|
+
}
|
|
1864
|
+
return patch;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function applyImpl(ctx, config) {
|
|
1868
|
+
const stateFilePath = resolveStateFilePath(ctx);
|
|
1869
|
+
const persisted = loadPersistedState(stateFilePath);
|
|
1870
|
+
|
|
1871
|
+
const readExistingPiAiRoute = (provider) => {
|
|
1872
|
+
try {
|
|
1873
|
+
const descriptors = ctx.settings.describe();
|
|
1874
|
+
const piAi = descriptors.find((d) => d.ns === PI_AI_NS);
|
|
1875
|
+
return piAi?.value?.providers?.[provider];
|
|
1876
|
+
} catch {
|
|
1877
|
+
return undefined;
|
|
1878
|
+
}
|
|
1879
|
+
};
|
|
1880
|
+
|
|
1881
|
+
const readDefaultModel = () => {
|
|
1882
|
+
try {
|
|
1883
|
+
const value = ctx.settings.describe().find((d) => d.ns === DEFAULT_MODEL_NS)?.value;
|
|
1884
|
+
return typeof value?.provider === "string" && typeof value?.model === "string"
|
|
1885
|
+
? { provider: value.provider, model: value.model }
|
|
1886
|
+
: undefined;
|
|
1887
|
+
} catch {
|
|
1888
|
+
return undefined;
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
|
|
1892
|
+
// Seed a selection from every managed route, not just the base one, so a
|
|
1893
|
+
// restart after protocol routing keeps Messages / Responses models checked.
|
|
1894
|
+
const existingManagedIds = (provider) =>
|
|
1895
|
+
managedRouteKeys(provider).flatMap((key) => {
|
|
1896
|
+
const route = readExistingPiAiRoute(key);
|
|
1897
|
+
return Array.isArray(route?.models)
|
|
1898
|
+
? route.models.map((m) => m?.id).filter((id) => typeof id === "string" && id.trim() !== "")
|
|
1899
|
+
: [];
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
// Migrate a legacy single-account file into `accounts[]`, keeping a copy.
|
|
1903
|
+
if (Object.keys(persisted).length > 0 && !Array.isArray(persisted.accounts)) {
|
|
1904
|
+
savePersistedState(stateFilePath.replace(/\.json$/, ".v1.json"), persisted);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// An account without a saved selection keeps the models already on its
|
|
1908
|
+
// pi-ai routes when that is a curated list (<= 35). Otherwise it stays
|
|
1909
|
+
// unset and its first successful probe seeds recommendModels().
|
|
1910
|
+
let memState = normalizeState(mergeHostConfig(persisted, config));
|
|
1911
|
+
memState = {
|
|
1912
|
+
...memState,
|
|
1913
|
+
accounts: memState.accounts.map((account) => {
|
|
1914
|
+
if (account.selectedModels !== undefined) return account;
|
|
1915
|
+
const existingIds = existingManagedIds(account.provider);
|
|
1916
|
+
return existingIds.length > 0 && existingIds.length <= 35
|
|
1917
|
+
? { ...account, selectedModels: existingIds }
|
|
1918
|
+
: account;
|
|
1919
|
+
}),
|
|
1920
|
+
};
|
|
1921
|
+
savePersistedState(stateFilePath, memState);
|
|
1922
|
+
|
|
1923
|
+
// The state file is re-read on every access so hand edits take effect.
|
|
1924
|
+
const readState = () => {
|
|
1925
|
+
const fresh = loadPersistedState(stateFilePath);
|
|
1926
|
+
if (Object.keys(fresh).length > 0) memState = normalizeState(fresh);
|
|
1927
|
+
return memState;
|
|
1928
|
+
};
|
|
1929
|
+
const writeState = (state) => {
|
|
1930
|
+
memState = state;
|
|
1931
|
+
savePersistedState(stateFilePath, state);
|
|
1932
|
+
};
|
|
1933
|
+
|
|
1934
|
+
let timer;
|
|
1935
|
+
let startupTimeout;
|
|
1936
|
+
|
|
1937
|
+
const resolveApiKey = async (refName) => {
|
|
1938
|
+
try {
|
|
1939
|
+
const ref = toCredentialRef(refName);
|
|
1940
|
+
const credentials = ctx.get("credentials");
|
|
1941
|
+
const stored = credentials === undefined ? undefined : await credentials.resolve(ref);
|
|
1942
|
+
const raw = stored?.value ?? getEnvValue(ctx, ref);
|
|
1943
|
+
const value = raw?.trim();
|
|
1944
|
+
return value === undefined || value === "" ? undefined : value;
|
|
1945
|
+
} catch {
|
|
1946
|
+
return undefined;
|
|
1947
|
+
}
|
|
1948
|
+
};
|
|
1949
|
+
|
|
1950
|
+
const describeApiKey = async (refName) => {
|
|
1951
|
+
try {
|
|
1952
|
+
const ref = toCredentialRef(refName);
|
|
1953
|
+
const credentials = ctx.get("credentials");
|
|
1954
|
+
if (credentials !== undefined) {
|
|
1955
|
+
return await credentials.describe(ref);
|
|
1956
|
+
}
|
|
1957
|
+
const envVal = getEnvValue(ctx, ref);
|
|
1958
|
+
return {
|
|
1959
|
+
configured: envVal !== undefined && envVal.length > 0,
|
|
1960
|
+
writable: false,
|
|
1961
|
+
};
|
|
1962
|
+
} catch {
|
|
1963
|
+
return { configured: false, writable: true };
|
|
1964
|
+
}
|
|
1965
|
+
};
|
|
1966
|
+
|
|
1967
|
+
const reschedule = () => {
|
|
1968
|
+
if (timer !== undefined) clearInterval(timer);
|
|
1969
|
+
timer = outsideHmrTransaction(ctx, () =>
|
|
1970
|
+
setInterval(() => {
|
|
1971
|
+
void controller.refreshAll();
|
|
1972
|
+
}, readState().refreshIntervalSeconds * 1000)
|
|
1973
|
+
);
|
|
1974
|
+
};
|
|
1975
|
+
|
|
1976
|
+
const controller = createCpaController({
|
|
1977
|
+
settings: ctx.settings,
|
|
1978
|
+
readState,
|
|
1979
|
+
writeState,
|
|
1980
|
+
resolveApiKey,
|
|
1981
|
+
describeApiKey,
|
|
1982
|
+
setCredential: async (ref, value) => {
|
|
1983
|
+
const credentials = ctx.get("credentials");
|
|
1984
|
+
if (credentials === undefined) {
|
|
1985
|
+
throw new Error("Credentials service is not available on this host");
|
|
1986
|
+
}
|
|
1987
|
+
await credentials.set(ref, value);
|
|
1988
|
+
},
|
|
1989
|
+
unsetCredential: async (ref) => {
|
|
1990
|
+
await ctx.get("credentials")?.unset(ref);
|
|
1991
|
+
},
|
|
1992
|
+
readExistingPiAiRoute,
|
|
1993
|
+
readDefaultModel,
|
|
1994
|
+
setTimeout: (fn, ms) => outsideHmrTransaction(ctx, () => setTimeout(fn, ms)),
|
|
1995
|
+
environmentBaseURL: () => getEnvValue(ctx, "CLIPROXYAPI_BASE_URL"),
|
|
1996
|
+
logger: ctx.logger,
|
|
1997
|
+
onGlobalsChanged: () => reschedule(),
|
|
1998
|
+
});
|
|
1999
|
+
|
|
2000
|
+
reschedule();
|
|
2001
|
+
startupTimeout = outsideHmrTransaction(ctx, () =>
|
|
2002
|
+
setTimeout(() => {
|
|
2003
|
+
startupTimeout = undefined;
|
|
2004
|
+
void controller.refreshAll();
|
|
2005
|
+
}, 600)
|
|
2006
|
+
);
|
|
2007
|
+
|
|
2008
|
+
ctx.on("credentials/reference-updated", (ref) => {
|
|
2009
|
+
for (const id of controller.accountsUsingKey(ref)) {
|
|
2010
|
+
void controller.refreshAccount(id);
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
ctx.effect(
|
|
2015
|
+
() => () => {
|
|
2016
|
+
if (timer !== undefined) clearInterval(timer);
|
|
2017
|
+
if (startupTimeout !== undefined) clearTimeout(startupTimeout);
|
|
2018
|
+
controller.dispose();
|
|
2019
|
+
},
|
|
2020
|
+
"cpa: refresh loop"
|
|
2021
|
+
);
|
|
2022
|
+
|
|
2023
|
+
// Register slash commands (/cpa-status, /cpa-refresh)
|
|
2024
|
+
ctx.inject(["commands"], (cmdCtx) => {
|
|
2025
|
+
cmdCtx.effect(() => {
|
|
2026
|
+
const offStatus = cmdCtx.commands.register({
|
|
2027
|
+
name: "cpa-status",
|
|
2028
|
+
description: "Show CLIProxyAPI discovery and model synchronization status of every account.",
|
|
2029
|
+
handler: async () => {
|
|
2030
|
+
const payload = await controller.statusPayload();
|
|
2031
|
+
return { kind: "success", text: renderStatusText(payload) };
|
|
2032
|
+
},
|
|
2033
|
+
});
|
|
2034
|
+
const offRefresh = cmdCtx.commands.register({
|
|
2035
|
+
name: "cpa-refresh",
|
|
2036
|
+
description: "Probe every CLIProxyAPI account now and refresh the synchronized DSH model catalog.",
|
|
2037
|
+
handler: async () => {
|
|
2038
|
+
const payload = await controller.refresh(undefined);
|
|
2039
|
+
const failed = payload.accounts.some((a) => a.status.state === "error");
|
|
2040
|
+
return { kind: failed ? "error" : "success", text: renderStatusText(payload) };
|
|
2041
|
+
},
|
|
2042
|
+
});
|
|
2043
|
+
return () => {
|
|
2044
|
+
offStatus?.();
|
|
2045
|
+
offRefresh?.();
|
|
2046
|
+
};
|
|
2047
|
+
}, "cpa: commands");
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
// Register Agent Tool (cliproxyapi) directly via ctx.tools.register
|
|
2051
|
+
ctx.inject(["tools"], (toolCtx) => {
|
|
2052
|
+
toolCtx.effect(() => {
|
|
2053
|
+
return toolCtx.tools.register({
|
|
2054
|
+
name: "cliproxyapi",
|
|
2055
|
+
description:
|
|
2056
|
+
"Inspect, refresh, or configure CLIProxyAPI accounts (each one a CPA server + API key synchronized into its own DSH provider routes), add or remove accounts, enable or disable them, and filter which models each one shows in the DSH model selector.",
|
|
2057
|
+
parameters: {
|
|
2058
|
+
type: "object",
|
|
2059
|
+
additionalProperties: false,
|
|
2060
|
+
required: ["action"],
|
|
2061
|
+
properties: {
|
|
2062
|
+
action: {
|
|
2063
|
+
type: "string",
|
|
2064
|
+
enum: ["status", "refresh", "configure", "add_account", "remove_account"],
|
|
2065
|
+
description:
|
|
2066
|
+
"'status' reads every account (or only `account`), 'refresh' probes and syncs now, 'configure' updates one account, 'add_account' creates an account from the other fields, 'remove_account' deletes `account` and its model routes.",
|
|
2067
|
+
},
|
|
2068
|
+
account: {
|
|
2069
|
+
type: "string",
|
|
2070
|
+
description:
|
|
2071
|
+
"Target account id (or its provider route ID / display name) for status, refresh, configure and remove_account. Defaults to the first account; refresh without it refreshes all.",
|
|
2072
|
+
},
|
|
2073
|
+
enabled: {
|
|
2074
|
+
type: "boolean",
|
|
2075
|
+
description:
|
|
2076
|
+
"Enable or disable the account. A disabled account keeps its settings but its models leave the picker. add_account defaults to enabled when baseURL is given.",
|
|
2077
|
+
},
|
|
2078
|
+
baseURL: {
|
|
2079
|
+
type: "string",
|
|
2080
|
+
description:
|
|
2081
|
+
"CLIProxyAPI base URL (e.g. http://127.0.0.1:8317). Empty means auto-discover locally.",
|
|
2082
|
+
},
|
|
2083
|
+
provider: {
|
|
2084
|
+
type: "string",
|
|
2085
|
+
description:
|
|
2086
|
+
"Provider route ID in llm-pi-ai; must be unique per account (default: cli-proxy, then cli-proxy-2, ...).",
|
|
2087
|
+
},
|
|
2088
|
+
displayName: {
|
|
2089
|
+
type: "string",
|
|
2090
|
+
description: "Account name shown in the DSH model picker.",
|
|
2091
|
+
},
|
|
2092
|
+
protocolMode: {
|
|
2093
|
+
type: "string",
|
|
2094
|
+
enum: ["auto", "fixed"],
|
|
2095
|
+
description:
|
|
2096
|
+
"'auto' (default) sends each model over its own family's protocol: Claude via Anthropic Messages, GPT/Codex/Grok via OpenAI Responses, others (incl. Gemini) via OpenAI Chat. 'fixed' sends every model over `protocol`.",
|
|
2097
|
+
},
|
|
2098
|
+
protocol: {
|
|
2099
|
+
type: "string",
|
|
2100
|
+
enum: ["openai-completions", "openai-responses", "anthropic-messages"],
|
|
2101
|
+
description: "Wire protocol used for every model when protocolMode is 'fixed'.",
|
|
2102
|
+
},
|
|
2103
|
+
selectedModels: {
|
|
2104
|
+
type: "array",
|
|
2105
|
+
items: { type: "string" },
|
|
2106
|
+
description:
|
|
2107
|
+
"Exact model IDs to show in the DSH model picker. Pass an empty array [] to show all discovered models.",
|
|
2108
|
+
},
|
|
2109
|
+
includePrefix: {
|
|
2110
|
+
type: "string",
|
|
2111
|
+
description: "Comma-separated model ID substrings/prefixes to include.",
|
|
2112
|
+
},
|
|
2113
|
+
excludePrefix: {
|
|
2114
|
+
type: "string",
|
|
2115
|
+
description: "Comma-separated model ID substrings/prefixes to exclude.",
|
|
2116
|
+
},
|
|
2117
|
+
apiKey: {
|
|
2118
|
+
type: "string",
|
|
2119
|
+
description:
|
|
2120
|
+
"CLIProxyAPI Bearer key, stored in the DSH credentials service under the account's apiKeyEnv.",
|
|
2121
|
+
},
|
|
2122
|
+
apiKeyEnv: {
|
|
2123
|
+
type: "string",
|
|
2124
|
+
description:
|
|
2125
|
+
"Credential reference name holding the account's key (default: CLI_PROXY_API_KEY, then CLI_PROXY_API_KEY_2, ...).",
|
|
2126
|
+
},
|
|
2127
|
+
modelOverrides: {
|
|
2128
|
+
type: "object",
|
|
2129
|
+
description:
|
|
2130
|
+
"Per-model overrides keyed by model ID, replacing the account's whole map: { [modelId]: { contextWindow?: number, maxTokens?: number, image?: boolean, reasoningLevels?: string[], protocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages' } }.",
|
|
2131
|
+
},
|
|
2132
|
+
},
|
|
2133
|
+
},
|
|
2134
|
+
output: {
|
|
2135
|
+
schema: {
|
|
2136
|
+
type: "object",
|
|
2137
|
+
additionalProperties: false,
|
|
2138
|
+
required: ["accounts", "summary"],
|
|
2139
|
+
properties: {
|
|
2140
|
+
account: { type: "string" },
|
|
2141
|
+
accounts: {
|
|
2142
|
+
type: "array",
|
|
2143
|
+
items: {
|
|
2144
|
+
type: "object",
|
|
2145
|
+
additionalProperties: false,
|
|
2146
|
+
required: ["id", "displayName", "provider", "enabled", "state", "modelCount", "totalDiscovered"],
|
|
2147
|
+
properties: {
|
|
2148
|
+
id: { type: "string" },
|
|
2149
|
+
displayName: { type: "string" },
|
|
2150
|
+
provider: { type: "string" },
|
|
2151
|
+
enabled: { type: "boolean" },
|
|
2152
|
+
state: { type: "string" },
|
|
2153
|
+
endpoint: { type: "string" },
|
|
2154
|
+
modelCount: { type: "integer" },
|
|
2155
|
+
totalDiscovered: { type: "integer" },
|
|
2156
|
+
error: { type: "string" },
|
|
2157
|
+
},
|
|
2158
|
+
},
|
|
2159
|
+
},
|
|
2160
|
+
summary: { type: "string" },
|
|
2161
|
+
},
|
|
2162
|
+
},
|
|
2163
|
+
render: (_args, value) => [{ type: "text", text: value.summary }],
|
|
2164
|
+
},
|
|
2165
|
+
async execute(args) {
|
|
2166
|
+
const input = args ?? {};
|
|
2167
|
+
const ref = typeof input.account === "string" && input.account.trim() !== "" ? input.account.trim() : undefined;
|
|
2168
|
+
let target;
|
|
2169
|
+
if (input.action === "refresh") {
|
|
2170
|
+
target = (await controller.refresh(ref)).account;
|
|
2171
|
+
} else if (input.action === "configure") {
|
|
2172
|
+
target = (await controller.updateAccount(ref, accountPatchFromInput(input))).account;
|
|
2173
|
+
} else if (input.action === "add_account") {
|
|
2174
|
+
const patch = accountPatchFromInput(input);
|
|
2175
|
+
if (patch.enabled === undefined) {
|
|
2176
|
+
patch.enabled = typeof patch.baseURL === "string" && patch.baseURL.trim() !== "";
|
|
2177
|
+
}
|
|
2178
|
+
target = (await controller.addAccount(patch)).createdId;
|
|
2179
|
+
} else if (input.action === "remove_account") {
|
|
2180
|
+
if (ref === undefined) throw new Error("remove_account needs `account`");
|
|
2181
|
+
await controller.removeAccount(ref);
|
|
2182
|
+
} else if (ref !== undefined) {
|
|
2183
|
+
target = controller.findAccount(ref).id;
|
|
2184
|
+
}
|
|
2185
|
+
const payload = await controller.statusPayload();
|
|
2186
|
+
return {
|
|
2187
|
+
...(target === undefined ? {} : { account: target }),
|
|
2188
|
+
accounts: payload.accounts.map(summarizeAccount),
|
|
2189
|
+
summary: renderStatusText(payload, input.action === "status" ? target : undefined),
|
|
2190
|
+
};
|
|
2191
|
+
},
|
|
2192
|
+
presentCall: (args) => ({
|
|
2193
|
+
card: "generic",
|
|
2194
|
+
title: `CLIProxyAPI: ${args?.action ?? "status"}${args?.account ? ` (${args.account})` : ""}`,
|
|
2195
|
+
kind: args?.action === "status" ? "read" : "execute",
|
|
2196
|
+
rawInput: args,
|
|
2197
|
+
}),
|
|
2198
|
+
});
|
|
2199
|
+
}, "cpa: tool");
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
// Register HTTP API Routes on webServer for the Web UI
|
|
2203
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
2204
|
+
webCtx.effect(() => {
|
|
2205
|
+
const route = (path, method, run, errorStatus = 500) =>
|
|
2206
|
+
webCtx.webServer.register({
|
|
2207
|
+
kind: "exact",
|
|
2208
|
+
path,
|
|
2209
|
+
handler: async (req, res) => {
|
|
2210
|
+
if (!admitRequest(ctx, req, res, method)) return;
|
|
2211
|
+
try {
|
|
2212
|
+
const body = method === "POST" ? (await readJsonBody(req)) ?? {} : {};
|
|
2213
|
+
sendJson(res, 200, await run(body));
|
|
2214
|
+
} catch (error) {
|
|
2215
|
+
sendJson(res, errorStatus, {
|
|
2216
|
+
ok: false,
|
|
2217
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
},
|
|
2221
|
+
});
|
|
2222
|
+
const disposers = [
|
|
2223
|
+
route(ROUTES.status, "GET", () => controller.statusPayload()),
|
|
2224
|
+
route(ROUTES.refresh, "POST", (body) => controller.refresh(body.accountId)),
|
|
2225
|
+
route(
|
|
2226
|
+
ROUTES.save,
|
|
2227
|
+
"POST",
|
|
2228
|
+
(body) => {
|
|
2229
|
+
const patch = accountPatchFromInput(body);
|
|
2230
|
+
const hasAccountFields = Object.keys(patch).some((k) => !GLOBAL_FIELDS.includes(k));
|
|
2231
|
+
return body.accountId === undefined && !hasAccountFields
|
|
2232
|
+
? controller.updateGlobals(patch)
|
|
2233
|
+
: controller.updateAccount(body.accountId, patch);
|
|
2234
|
+
},
|
|
2235
|
+
400
|
|
2236
|
+
),
|
|
2237
|
+
route(ROUTES.addAccount, "POST", (body) => controller.addAccount(accountPatchFromInput(body)), 400),
|
|
2238
|
+
route(ROUTES.removeAccount, "POST", (body) => controller.removeAccount(body.accountId), 400),
|
|
2239
|
+
];
|
|
2240
|
+
return () => {
|
|
2241
|
+
for (const dispose of disposers) dispose?.();
|
|
2242
|
+
};
|
|
2243
|
+
}, "cpa: webserver routes");
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
export const apply = mountOnce(PACKAGE_NAME, applyImpl);
|
|
2248
|
+
|
|
2249
|
+
// #endregion
|