@raingor/pi-web-switch 0.3.2 → 0.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/README.ja.md +17 -1
- package/README.md +17 -1
- package/README.zh-CN.md +17 -1
- package/package.json +46 -3
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/pi.svg +47 -4
- package/server/pi-reader.ts +750 -4
- package/src/App.tsx +2 -0
- package/src/components/layout/Sidebar.tsx +3 -5
- package/src/components/providers/ProvidersModelsPage.tsx +1171 -124
- package/src/components/settings/SettingsPage.tsx +74 -55
- package/src/components/subagents/SubagentsPage.tsx +502 -0
- package/src/data/builtin-providers.ts +15 -7
- package/src/data/model-catalog.ts +967 -0
- package/src/index.css +5 -1
- package/src/lib/config.ts +61 -0
- package/src/lib/translations/en.ts +91 -1
- package/src/lib/translations/ja.ts +91 -1
- package/src/lib/translations/zh-CN.ts +91 -1
- package/src/lib/translations/zh-TW.ts +91 -1
- package/src/main.tsx +9 -0
- package/src/store/config-store.ts +75 -28
- package/src/types/index.ts +52 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +58 -2
- package/tsconfig.tsbuildinfo +0 -1
package/server/pi-reader.ts
CHANGED
|
@@ -550,7 +550,7 @@ function decodeProjectName(dirName: string): { projectPath: string; projectName:
|
|
|
550
550
|
}
|
|
551
551
|
// Use the last 1-2 path segments as the project name
|
|
552
552
|
const segments = displayName.split("/").filter(Boolean);
|
|
553
|
-
const projectName = segments.length > 0 ? segments[segments.length - 1] : dirName;
|
|
553
|
+
const projectName = segments.length > 0 ? (segments[segments.length - 1] ?? dirName) : dirName;
|
|
554
554
|
return { projectPath: decoded, projectName };
|
|
555
555
|
}
|
|
556
556
|
|
|
@@ -588,7 +588,7 @@ export function listSessions(): ProjectGroup[] {
|
|
|
588
588
|
|
|
589
589
|
group.totalSessions = group.sessions.length;
|
|
590
590
|
if (group.sessions.length > 0) {
|
|
591
|
-
group.lastActive = group.sessions[0]
|
|
591
|
+
group.lastActive = group.sessions[0]?.timestamp ?? ""; // already sorted newest-first
|
|
592
592
|
}
|
|
593
593
|
}
|
|
594
594
|
|
|
@@ -933,9 +933,78 @@ function isNewerVersion(installed: string, latest: string): boolean {
|
|
|
933
933
|
return false;
|
|
934
934
|
}
|
|
935
935
|
|
|
936
|
+
// ─── Outbound fetch with local proxy support ───────────
|
|
937
|
+
// Node's fetch ignores the OS proxy, so requests to some provider hosts fail
|
|
938
|
+
// on machines that rely on a local proxy (e.g. Clash). Detect a proxy from
|
|
939
|
+
// env vars or macOS system settings and route through it via undici's
|
|
940
|
+
// ProxyAgent; fall back to a direct request when no proxy or the proxy fails.
|
|
941
|
+
|
|
942
|
+
let undiciPromise: Promise<typeof import("undici") | null> | null = null;
|
|
943
|
+
function getUndici(): Promise<typeof import("undici") | null> {
|
|
944
|
+
// Dynamic import: require() of bare packages breaks inside Vite's bundled
|
|
945
|
+
// config (esbuild rewrites it to a throwing __require shim in ESM output).
|
|
946
|
+
if (!undiciPromise) {
|
|
947
|
+
undiciPromise = import("undici").catch(() => null);
|
|
948
|
+
}
|
|
949
|
+
return undiciPromise;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
let proxyCache: { url: string | null; at: number } | null = null;
|
|
953
|
+
|
|
954
|
+
function detectProxyUrl(): string | null {
|
|
955
|
+
if (proxyCache && Date.now() - proxyCache.at < 60000) return proxyCache.url;
|
|
956
|
+
let found: string | null =
|
|
957
|
+
process.env.https_proxy ||
|
|
958
|
+
process.env.HTTPS_PROXY ||
|
|
959
|
+
process.env.http_proxy ||
|
|
960
|
+
process.env.HTTP_PROXY ||
|
|
961
|
+
null;
|
|
962
|
+
if (found && !found.startsWith("http")) found = null; // ProxyAgent needs an HTTP proxy
|
|
963
|
+
if (!found && process.platform === "darwin") {
|
|
964
|
+
try {
|
|
965
|
+
const out = spawnSync("scutil", ["--proxy"], { encoding: "utf8", timeout: 3000 }).stdout || "";
|
|
966
|
+
const get = (k: string) => out.match(new RegExp(`${k} : (\\S+)`))?.[1];
|
|
967
|
+
if (get("HTTPSEnable") === "1" && get("HTTPSProxy")) {
|
|
968
|
+
found = `http://${get("HTTPSProxy")}:${get("HTTPSPort") ?? "80"}`;
|
|
969
|
+
} else if (get("HTTPEnable") === "1" && get("HTTPProxy")) {
|
|
970
|
+
found = `http://${get("HTTPProxy")}:${get("HTTPPort") ?? "80"}`;
|
|
971
|
+
}
|
|
972
|
+
} catch {
|
|
973
|
+
/* scutil unavailable — ignore */
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
proxyCache = { url: found, at: Date.now() };
|
|
977
|
+
return found;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const proxyAgents = new Map<string, import("undici").ProxyAgent>();
|
|
981
|
+
|
|
982
|
+
async function fetchExternal(
|
|
983
|
+
url: string | URL,
|
|
984
|
+
init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal }
|
|
985
|
+
): Promise<Response> {
|
|
986
|
+
const target = url instanceof URL ? url.toString() : url;
|
|
987
|
+
const proxy = detectProxyUrl();
|
|
988
|
+
const undici = proxy ? await getUndici() : null;
|
|
989
|
+
if (proxy && undici) {
|
|
990
|
+
try {
|
|
991
|
+
let agent = proxyAgents.get(proxy);
|
|
992
|
+
if (!agent) {
|
|
993
|
+
agent = new undici.ProxyAgent(proxy);
|
|
994
|
+
proxyAgents.set(proxy, agent);
|
|
995
|
+
}
|
|
996
|
+
// Node's built-in fetch rejects a foreign dispatcher — use undici's fetch
|
|
997
|
+
return (await undici.fetch(target, { ...init, dispatcher: agent })) as unknown as Response;
|
|
998
|
+
} catch {
|
|
999
|
+
/* proxy failed — fall through to a direct request */
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return await fetch(target, init);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
936
1005
|
async function fetchLatestVersion(pkgName: string): Promise<string | null> {
|
|
937
1006
|
try {
|
|
938
|
-
const res = await
|
|
1007
|
+
const res = await fetchExternal(`https://registry.npmjs.org/${encodeURIComponent(pkgName)}/latest`, {
|
|
939
1008
|
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
|
|
940
1009
|
headers: { accept: "application/json" },
|
|
941
1010
|
});
|
|
@@ -1062,7 +1131,7 @@ export async function testProviderConnection(
|
|
|
1062
1131
|
|
|
1063
1132
|
const started = Date.now();
|
|
1064
1133
|
try {
|
|
1065
|
-
const res = await
|
|
1134
|
+
const res = await fetchExternal(url, {
|
|
1066
1135
|
headers,
|
|
1067
1136
|
signal: AbortSignal.timeout(10000),
|
|
1068
1137
|
});
|
|
@@ -1075,3 +1144,680 @@ export async function testProviderConnection(
|
|
|
1075
1144
|
return { success: false, latencyMs, message: msg };
|
|
1076
1145
|
}
|
|
1077
1146
|
}
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Fetch the model list from a provider's endpoint server-side.
|
|
1151
|
+
* Supports multiple source types:
|
|
1152
|
+
* - OpenAI-compatible /models (default)
|
|
1153
|
+
* - OpenRouter /models (returns pricing, modality, context_length, top_provider.max_completion_tokens)
|
|
1154
|
+
* - Ollama /api/tags (returns model names + capabilities via /api/show)
|
|
1155
|
+
*
|
|
1156
|
+
* Returns full model metadata so the frontend can prefill the add-model form:
|
|
1157
|
+
* { id, name, contextWindow, maxTokens, reasoning, vision, cost }
|
|
1158
|
+
*
|
|
1159
|
+
* Reasoning / vision / contextWindow are also heuristically inferred from
|
|
1160
|
+
* the model id when the endpoint doesn't report them.
|
|
1161
|
+
*/
|
|
1162
|
+
export interface FetchedModel {
|
|
1163
|
+
id: string;
|
|
1164
|
+
name?: string;
|
|
1165
|
+
contextWindow?: number;
|
|
1166
|
+
maxTokens?: number;
|
|
1167
|
+
reasoning?: boolean;
|
|
1168
|
+
vision?: boolean;
|
|
1169
|
+
audio?: boolean;
|
|
1170
|
+
cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
|
|
1171
|
+
source: string; // "openai" | "openrouter" | "ollama" | "heuristic"
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// Parse a numeric value, handling K/M suffixes (e.g. "128k" → 128000)
|
|
1175
|
+
function toNum(v: any): number | undefined {
|
|
1176
|
+
if (typeof v === "number") return v;
|
|
1177
|
+
if (typeof v !== "string") return undefined;
|
|
1178
|
+
const m = v.match(/^([0-9]+)([KkMm]?)$/);
|
|
1179
|
+
if (!m) return undefined;
|
|
1180
|
+
const n = parseInt(m[1] ?? "0", 10);
|
|
1181
|
+
const u = (m[2] ?? "").toUpperCase();
|
|
1182
|
+
return u === "K" ? n * 1000 : u === "M" ? n * 1_000_000 : n;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Heuristic reasoning detection (server-side; mirrors client guessModelMeta)
|
|
1186
|
+
const REASONING_RE = /(^|[/_\-])(r1|o1|o3|o4|z1|reasoner|reasoning|qwq|deepseek-r|think)([/_\-:]|$)/i;
|
|
1187
|
+
const VISION_RE = /(vision|[-_]vl\b|multimodal|gpt-4o|gpt-5|claude-(sonnet|opus)|gemini|llama-.*vision|qwen.*vl|glm-.*v\b)/i;
|
|
1188
|
+
const AUDIO_RE = /(audio|whisper|tts|speech)/i;
|
|
1189
|
+
function heuristicFlags(id: string): { reasoning?: boolean; vision?: boolean; audio?: boolean; contextWindow?: number } {
|
|
1190
|
+
const k = id.toLowerCase();
|
|
1191
|
+
const reasoning = REASONING_RE.test(k);
|
|
1192
|
+
const vision = VISION_RE.test(k);
|
|
1193
|
+
const audio = AUDIO_RE.test(k);
|
|
1194
|
+
let contextWindow: number | undefined;
|
|
1195
|
+
if (/[-_](1m|1024k|1048576)\b/i.test(k)) contextWindow = 1_048_576;
|
|
1196
|
+
else if (/[-_](256k)\b/i.test(k)) contextWindow = 262_144;
|
|
1197
|
+
else if (/[-_](128k)\b/i.test(k)) contextWindow = 131_072;
|
|
1198
|
+
else if (/[-_](64k)\b/i.test(k)) contextWindow = 65_536;
|
|
1199
|
+
else if (/[-_](32k)\b/i.test(k)) contextWindow = 32_768;
|
|
1200
|
+
else if (/[-_](16k)\b/i.test(k)) contextWindow = 16_384;
|
|
1201
|
+
else if (/[-_](8k)\b/i.test(k)) contextWindow = 8192;
|
|
1202
|
+
return { reasoning, vision, audio, contextWindow };
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function isOpenRouter(baseUrl: string, host: string): boolean {
|
|
1206
|
+
return host === "openrouter.ai" || host.endsWith(".openrouter.ai") ||
|
|
1207
|
+
baseUrl.includes("openrouter.ai");
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function fetchJson(url: URL, headers: Record<string, string>, timeoutMs = 15000) {
|
|
1211
|
+
const res = await fetchExternal(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
1212
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1213
|
+
const text = await res.text();
|
|
1214
|
+
const trimmed = text.trimStart();
|
|
1215
|
+
const ctype = res.headers.get("content-type") ?? "";
|
|
1216
|
+
if (trimmed.startsWith("<") || ctype.includes("text/html")) {
|
|
1217
|
+
// The endpoint returned an HTML page (often a site root / 404 served as 200)
|
|
1218
|
+
// instead of JSON — almost always a wrong base URL (missing /v1 prefix, etc.).
|
|
1219
|
+
throw new Error(`endpoint returned HTML, not JSON (check base URL): ${url.toString()}`);
|
|
1220
|
+
}
|
|
1221
|
+
try {
|
|
1222
|
+
return JSON.parse(text);
|
|
1223
|
+
} catch {
|
|
1224
|
+
throw new Error(`invalid JSON from ${url.toString()}`);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function makeHeaders(key: string, providerId?: string, host?: string): Record<string, string> {
|
|
1229
|
+
const headers: Record<string, string> = {};
|
|
1230
|
+
if (!key) return headers;
|
|
1231
|
+
if (providerId === "anthropic" || (host && host.endsWith("api.anthropic.com"))) {
|
|
1232
|
+
headers["x-api-key"] = key;
|
|
1233
|
+
headers["anthropic-version"] = "2023-06-01";
|
|
1234
|
+
} else if (providerId === "google" || (host && host.endsWith("generativelanguage.googleapis.com"))) {
|
|
1235
|
+
// Google uses query param; caller handles it
|
|
1236
|
+
} else {
|
|
1237
|
+
headers["Authorization"] = `Bearer ${key}`;
|
|
1238
|
+
}
|
|
1239
|
+
return headers;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
export async function fetchProviderModels(
|
|
1243
|
+
baseUrl: string,
|
|
1244
|
+
apiKey?: string,
|
|
1245
|
+
providerId?: string
|
|
1246
|
+
): Promise<{ models: FetchedModel[]; error?: string }> {
|
|
1247
|
+
let key = apiKey ?? "";
|
|
1248
|
+
if (key.startsWith("$")) key = process.env[key.slice(1)] ?? "";
|
|
1249
|
+
|
|
1250
|
+
let base: URL;
|
|
1251
|
+
try {
|
|
1252
|
+
base = new URL(baseUrl.replace(/\/+$/, ""));
|
|
1253
|
+
} catch {
|
|
1254
|
+
return { models: [], error: "invalid URL" };
|
|
1255
|
+
}
|
|
1256
|
+
if (base.protocol !== "http:" && base.protocol !== "https:") {
|
|
1257
|
+
return { models: [], error: "invalid URL" };
|
|
1258
|
+
}
|
|
1259
|
+
const host = base.hostname;
|
|
1260
|
+
|
|
1261
|
+
try {
|
|
1262
|
+
// ── Ollama: /api/tags (no /models) ──────────────────
|
|
1263
|
+
// Heuristic: local port 11434 or hostname "localhost" + path includes ollama
|
|
1264
|
+
const isOllama = host === "localhost" && base.port === "11434";
|
|
1265
|
+
if (isOllama) {
|
|
1266
|
+
const tagsUrl = new URL("/api/tags", base);
|
|
1267
|
+
const data = await fetchJson(tagsUrl, {});
|
|
1268
|
+
const models: FetchedModel[] = [];
|
|
1269
|
+
const models_ = data?.models ?? [];
|
|
1270
|
+
for (const m of models_) {
|
|
1271
|
+
const id = typeof m === "string" ? m : m.name ?? m.model ?? "";
|
|
1272
|
+
if (!id) continue;
|
|
1273
|
+
const flags = heuristicFlags(id);
|
|
1274
|
+
// Ollama details: try /api/show for richer info (best-effort, ignore errors)
|
|
1275
|
+
let cw: number | undefined = flags.contextWindow;
|
|
1276
|
+
let mt: number | undefined;
|
|
1277
|
+
try {
|
|
1278
|
+
const showUrl = new URL("/api/show", base);
|
|
1279
|
+
const show = await fetch(showUrl.toString(), {
|
|
1280
|
+
method: "POST",
|
|
1281
|
+
headers: { "Content-Type": "application/json" },
|
|
1282
|
+
body: JSON.stringify({ name: id }),
|
|
1283
|
+
signal: AbortSignal.timeout(5000),
|
|
1284
|
+
}).then((r) => r.json());
|
|
1285
|
+
cw = toNum(show?.model_info?.[`${show?.modelfile?.split("\n").find((l: string) => l.startsWith("FROM")) ?? ""}`]) ?? cw;
|
|
1286
|
+
if (show?.context_length) cw = toNum(show.context_length) ?? cw;
|
|
1287
|
+
} catch { /* ignore */ }
|
|
1288
|
+
models.push({
|
|
1289
|
+
id,
|
|
1290
|
+
contextWindow: cw,
|
|
1291
|
+
maxTokens: mt,
|
|
1292
|
+
reasoning: flags.reasoning,
|
|
1293
|
+
vision: flags.vision,
|
|
1294
|
+
audio: flags.audio,
|
|
1295
|
+
source: "ollama",
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
return { models };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// ── OpenRouter /models returns rich metadata ────────
|
|
1302
|
+
// Append to the full base path (preserve prefixes like /v1 or /v1beta).
|
|
1303
|
+
// Using new URL("/models", base) would resolve against the origin and drop the prefix.
|
|
1304
|
+
const modelsUrl = new URL(base.toString().replace(/\/+$/, "") + "/models");
|
|
1305
|
+
if (providerId === "google" || host.endsWith("generativelanguage.googleapis.com")) {
|
|
1306
|
+
if (key) modelsUrl.searchParams.set("key", key);
|
|
1307
|
+
}
|
|
1308
|
+
const headers = makeHeaders(key, providerId, host);
|
|
1309
|
+
const data = await fetchJson(modelsUrl, headers, isOpenRouter(baseUrl, host) ? 20000 : 15000);
|
|
1310
|
+
|
|
1311
|
+
const seen = new Set<string>();
|
|
1312
|
+
const models: FetchedModel[] = [];
|
|
1313
|
+
const pushModel = (m: FetchedModel) => {
|
|
1314
|
+
const v = (m.id ?? "").trim();
|
|
1315
|
+
if (!v || seen.has(v)) return;
|
|
1316
|
+
seen.add(v);
|
|
1317
|
+
// Apply heuristic defaults for fields the endpoint didn't provide
|
|
1318
|
+
const flags = heuristicFlags(v);
|
|
1319
|
+
m.reasoning = m.reasoning ?? flags.reasoning;
|
|
1320
|
+
m.vision = m.vision ?? flags.vision;
|
|
1321
|
+
m.audio = m.audio ?? flags.audio;
|
|
1322
|
+
m.contextWindow = m.contextWindow ?? flags.contextWindow;
|
|
1323
|
+
models.push(m);
|
|
1324
|
+
};
|
|
1325
|
+
|
|
1326
|
+
// Vision / modality detectors (vendor-specific shapes)
|
|
1327
|
+
const visionOf = (item: any): boolean | undefined => {
|
|
1328
|
+
if (!item || typeof item !== "object") return undefined;
|
|
1329
|
+
if (typeof item.capabilities?.vision === "boolean") return item.capabilities.vision;
|
|
1330
|
+
if (item.supports_vision === true || item.vision === true) return true;
|
|
1331
|
+
const mods = item.architecture?.input_modalities ?? item.input_modalities ?? item.modalities;
|
|
1332
|
+
if (Array.isArray(mods)) return mods.includes("image");
|
|
1333
|
+
const modality = item.architecture?.modality;
|
|
1334
|
+
if (typeof modality === "string") {
|
|
1335
|
+
return (modality.split("->")[0] ?? "").includes("image");
|
|
1336
|
+
}
|
|
1337
|
+
return undefined;
|
|
1338
|
+
};
|
|
1339
|
+
const audioOf = (item: any): boolean | undefined => {
|
|
1340
|
+
const mods = item.architecture?.input_modalities ?? item.input_modalities ?? item.modalities;
|
|
1341
|
+
if (Array.isArray(mods)) return mods.includes("audio");
|
|
1342
|
+
return undefined;
|
|
1343
|
+
};
|
|
1344
|
+
const reasoningOf = (item: any): boolean | undefined => {
|
|
1345
|
+
// OpenRouter doesn't directly expose a reasoning flag, but some providers
|
|
1346
|
+
// indicate it via architecture or the id contains reasoner/r1/o1/o3.
|
|
1347
|
+
if (item?.reasoning === true || item?.supports_reasoning === true) return true;
|
|
1348
|
+
return undefined;
|
|
1349
|
+
};
|
|
1350
|
+
const parseCost = (pricing: any): FetchedModel["cost"] | undefined => {
|
|
1351
|
+
if (!pricing) return undefined;
|
|
1352
|
+
// OpenRouter: pricing.prompt, pricing.completion, pricing.cache_read, pricing.cache_write
|
|
1353
|
+
// Values are per-token; multiply by 1e6 for $/M.
|
|
1354
|
+
const toDollar = (v: any) => typeof v === "string" ? parseFloat(v) * 1_000_000 : (typeof v === "number" ? v * 1_000_000 : undefined);
|
|
1355
|
+
const input = toDollar(pricing.prompt ?? pricing.input);
|
|
1356
|
+
const output = toDollar(pricing.completion ?? pricing.output);
|
|
1357
|
+
const cacheRead = toDollar(pricing.cache_read ?? pricing.cacheRead);
|
|
1358
|
+
const cacheWrite = toDollar(pricing.cache_write ?? pricing.cacheWrite);
|
|
1359
|
+
if (input === undefined && output === undefined) return undefined;
|
|
1360
|
+
return { input: input ?? 0, output: output ?? 0, cacheRead, cacheWrite };
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
const parseItem = (item: any) => {
|
|
1364
|
+
const rawId = typeof item === "string" ? item : item?.id ?? item?.model ?? item?.name ?? "";
|
|
1365
|
+
const id = typeof rawId === "string" ? rawId.replace(/^models\//, "") : "";
|
|
1366
|
+
if (!id) return;
|
|
1367
|
+
const name = typeof item?.name === "string" ? item.name : undefined;
|
|
1368
|
+
const cw =
|
|
1369
|
+
toNum(item?.context_length) ??
|
|
1370
|
+
toNum(item?.max_context) ??
|
|
1371
|
+
toNum(item?.context_window) ??
|
|
1372
|
+
toNum(item?.inputTokenLimit) ??
|
|
1373
|
+
toNum(item?.max_tokens) ??
|
|
1374
|
+
undefined;
|
|
1375
|
+
const mt =
|
|
1376
|
+
toNum(item?.max_output_tokens) ??
|
|
1377
|
+
toNum(item?.top_provider?.max_completion_tokens) ??
|
|
1378
|
+
toNum(item?.max_completion_tokens) ??
|
|
1379
|
+
toNum(item?.outputTokenLimit) ??
|
|
1380
|
+
toNum(item?.max_tokens) ??
|
|
1381
|
+
undefined;
|
|
1382
|
+
const isOR = isOpenRouter(baseUrl, host);
|
|
1383
|
+
pushModel({
|
|
1384
|
+
id,
|
|
1385
|
+
name: name !== id ? name : undefined,
|
|
1386
|
+
contextWindow: cw,
|
|
1387
|
+
maxTokens: mt,
|
|
1388
|
+
reasoning: reasoningOf(item),
|
|
1389
|
+
vision: visionOf(item),
|
|
1390
|
+
audio: audioOf(item),
|
|
1391
|
+
cost: isOR ? parseCost(item.pricing) : undefined,
|
|
1392
|
+
source: isOR ? "openrouter" : "openai",
|
|
1393
|
+
});
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
if (Array.isArray(data)) {
|
|
1397
|
+
data.forEach(parseItem);
|
|
1398
|
+
} else if (data && typeof data === "object") {
|
|
1399
|
+
const dataArr = data.data ?? data.models ?? data.models_list ?? null;
|
|
1400
|
+
if (Array.isArray(dataArr)) dataArr.forEach(parseItem);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
return { models };
|
|
1404
|
+
} catch (e: any) {
|
|
1405
|
+
const msg = e?.name === "TimeoutError" ? "timeout" : e?.cause?.code || e?.message || String(e);
|
|
1406
|
+
return { models: [], error: msg };
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Send a minimal /chat/completions request with a specific model ID to verify
|
|
1413
|
+
* the model is usable. Returns { success, latencyMs, message }.
|
|
1414
|
+
*/
|
|
1415
|
+
export async function testModel(
|
|
1416
|
+
baseUrl: string,
|
|
1417
|
+
modelId: string,
|
|
1418
|
+
apiKey?: string,
|
|
1419
|
+
apiType: string = "openai-completions"
|
|
1420
|
+
): Promise<ProviderTestResult> {
|
|
1421
|
+
let url: URL;
|
|
1422
|
+
try {
|
|
1423
|
+
url = new URL(baseUrl.replace(/\/+$/, "") + "/chat/completions");
|
|
1424
|
+
} catch {
|
|
1425
|
+
return { success: false, message: "invalid URL" };
|
|
1426
|
+
}
|
|
1427
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1428
|
+
return { success: false, message: "invalid URL" };
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
let key = apiKey ?? "";
|
|
1432
|
+
if (key.startsWith("$")) key = process.env[key.slice(1)] ?? "";
|
|
1433
|
+
|
|
1434
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
1435
|
+
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
1436
|
+
|
|
1437
|
+
// Build a minimal, lightweight completion payload
|
|
1438
|
+
const body: Record<string, any> = {
|
|
1439
|
+
model: modelId,
|
|
1440
|
+
messages: [{ role: "user", content: "Reply with a single word: ok" }],
|
|
1441
|
+
max_tokens: 4,
|
|
1442
|
+
temperature: 0,
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
const started = Date.now();
|
|
1446
|
+
try {
|
|
1447
|
+
const res = await fetchExternal(url, {
|
|
1448
|
+
method: "POST",
|
|
1449
|
+
headers,
|
|
1450
|
+
body: JSON.stringify(body),
|
|
1451
|
+
signal: AbortSignal.timeout(15000),
|
|
1452
|
+
});
|
|
1453
|
+
const latencyMs = Date.now() - started;
|
|
1454
|
+
if (res.ok) {
|
|
1455
|
+
// Validate response — accept if we got valid JSON back
|
|
1456
|
+
const data = await res.json();
|
|
1457
|
+
const choice = data?.choices?.[0];
|
|
1458
|
+
const hasContent = choice && (choice.message?.content || choice.delta?.content);
|
|
1459
|
+
if (hasContent) {
|
|
1460
|
+
return { success: true, latencyMs };
|
|
1461
|
+
}
|
|
1462
|
+
// Got valid JSON but no choice content — check for alternative response formats
|
|
1463
|
+
const hasUsage = !!data?.usage;
|
|
1464
|
+
if (hasUsage) {
|
|
1465
|
+
return { success: true, latencyMs, message: "response received (no content)" };
|
|
1466
|
+
}
|
|
1467
|
+
return { success: false, latencyMs, message: "invalid response: " + JSON.stringify(data).slice(0, 150) };
|
|
1468
|
+
}
|
|
1469
|
+
// Capture the status line from the body if possible
|
|
1470
|
+
try {
|
|
1471
|
+
const d = await res.json();
|
|
1472
|
+
return { success: false, status: res.status, latencyMs, message: d?.error?.message || `HTTP ${res.status}` };
|
|
1473
|
+
} catch {
|
|
1474
|
+
return { success: false, status: res.status, latencyMs, message: `HTTP ${res.status}` };
|
|
1475
|
+
}
|
|
1476
|
+
} catch (e: any) {
|
|
1477
|
+
const latencyMs = Date.now() - started;
|
|
1478
|
+
const msg = e?.name === "TimeoutError" ? "timeout" : e?.message || String(e);
|
|
1479
|
+
return { success: false, latencyMs, message: msg };
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// ─── Subagents ────────────────────────────────────────────
|
|
1484
|
+
|
|
1485
|
+
const AGENTS_DIR = join(PI_DIR, "agents");
|
|
1486
|
+
const CHAINS_DIR = join(PI_DIR, "chains");
|
|
1487
|
+
const RUN_HISTORY_PATH = join(PI_DIR, "run-history.jsonl");
|
|
1488
|
+
|
|
1489
|
+
/** Parse YAML frontmatter from an agent/chain .md file. */
|
|
1490
|
+
function parseFrontmatter(raw: string): { frontmatter: Record<string, any>; body: string } {
|
|
1491
|
+
const frontmatter: Record<string, any> = {};
|
|
1492
|
+
const first = raw.indexOf("---");
|
|
1493
|
+
if (first !== 0) return { frontmatter, body: raw };
|
|
1494
|
+
const second = raw.indexOf("---", 3);
|
|
1495
|
+
if (second === -1) return { frontmatter, body: raw };
|
|
1496
|
+
const yamlLines = raw.slice(3, second).trim().split("\n");
|
|
1497
|
+
const body = raw.slice(second + 3).trim();
|
|
1498
|
+
|
|
1499
|
+
for (const line of yamlLines) {
|
|
1500
|
+
const colonIdx = line.indexOf(":");
|
|
1501
|
+
if (colonIdx === -1) continue;
|
|
1502
|
+
const key = line.slice(0, colonIdx).trim();
|
|
1503
|
+
let value: any = line.slice(colonIdx + 1).trim();
|
|
1504
|
+
|
|
1505
|
+
// Array value: "[item1, item2]" or multiline "key:\n - item"
|
|
1506
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
1507
|
+
value = value.slice(1, -1).split(",").map((s: string) => s.trim().replace(/^["']|["']$/g, ""));
|
|
1508
|
+
} else if (value === "true" || value === "false") {
|
|
1509
|
+
value = value === "true";
|
|
1510
|
+
} else if (/^\d+$/.test(value)) {
|
|
1511
|
+
value = parseInt(value, 10);
|
|
1512
|
+
} else if (/^\d+\.\d+$/.test(value)) {
|
|
1513
|
+
value = parseFloat(value);
|
|
1514
|
+
} else {
|
|
1515
|
+
value = value.replace(/^["']|["']$/g, "");
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
frontmatter[key] = value;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
return { frontmatter, body };
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
export interface AgentDef {
|
|
1525
|
+
name: string;
|
|
1526
|
+
fileName: string;
|
|
1527
|
+
filePath: string;
|
|
1528
|
+
package: string;
|
|
1529
|
+
description: string;
|
|
1530
|
+
model?: string;
|
|
1531
|
+
tools?: string[];
|
|
1532
|
+
thinking?: string;
|
|
1533
|
+
systemPromptMode?: string;
|
|
1534
|
+
inheritProjectContext?: boolean;
|
|
1535
|
+
inheritSkills?: boolean;
|
|
1536
|
+
input?: string[];
|
|
1537
|
+
body: string;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
export function listAgents(): AgentDef[] {
|
|
1541
|
+
try {
|
|
1542
|
+
if (!existsSync(AGENTS_DIR)) return [];
|
|
1543
|
+
const files = readdirSync(AGENTS_DIR).filter((f) => f.endsWith(".md"));
|
|
1544
|
+
return files.map((fileName) => {
|
|
1545
|
+
const filePath = join(AGENTS_DIR, fileName);
|
|
1546
|
+
try {
|
|
1547
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
1548
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
1549
|
+
function splitMaybe(val: unknown): string[] | undefined {
|
|
1550
|
+
if (Array.isArray(val)) return val.map(String);
|
|
1551
|
+
if (typeof val === "string" && val.trim()) return val.split(/\s*,\s*/).filter(Boolean);
|
|
1552
|
+
return undefined;
|
|
1553
|
+
}
|
|
1554
|
+
return {
|
|
1555
|
+
name: frontmatter.name || fileName.replace(/\.md$/, ""),
|
|
1556
|
+
fileName,
|
|
1557
|
+
filePath,
|
|
1558
|
+
package: frontmatter.package || "custom",
|
|
1559
|
+
description: frontmatter.description || "",
|
|
1560
|
+
model: frontmatter.model,
|
|
1561
|
+
tools: splitMaybe(frontmatter.tools),
|
|
1562
|
+
thinking: frontmatter.thinking,
|
|
1563
|
+
systemPromptMode: frontmatter.systemPromptMode,
|
|
1564
|
+
inheritProjectContext: frontmatter.inheritProjectContext,
|
|
1565
|
+
inheritSkills: frontmatter.inheritSkills,
|
|
1566
|
+
input: splitMaybe(frontmatter.input),
|
|
1567
|
+
body: body.slice(0, 500),
|
|
1568
|
+
};
|
|
1569
|
+
} catch {
|
|
1570
|
+
return null;
|
|
1571
|
+
}
|
|
1572
|
+
}).filter(Boolean) as AgentDef[];
|
|
1573
|
+
} catch {
|
|
1574
|
+
return [];
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
export interface ChainStep {
|
|
1579
|
+
agent: string;
|
|
1580
|
+
phase?: string;
|
|
1581
|
+
label?: string;
|
|
1582
|
+
output?: string;
|
|
1583
|
+
as?: string;
|
|
1584
|
+
task?: string;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
export interface ChainDef {
|
|
1588
|
+
name: string;
|
|
1589
|
+
fileName: string;
|
|
1590
|
+
filePath: string;
|
|
1591
|
+
description: string;
|
|
1592
|
+
steps: ChainStep[];
|
|
1593
|
+
body: string;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
export function listChains(): ChainDef[] {
|
|
1597
|
+
try {
|
|
1598
|
+
if (!existsSync(CHAINS_DIR)) return [];
|
|
1599
|
+
const files = readdirSync(CHAINS_DIR).filter((f) => f.endsWith(".chain.md"));
|
|
1600
|
+
return files.map((fileName) => {
|
|
1601
|
+
const filePath = join(CHAINS_DIR, fileName);
|
|
1602
|
+
try {
|
|
1603
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
1604
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
1605
|
+
const steps: ChainStep[] = [];
|
|
1606
|
+
|
|
1607
|
+
// Parse chain steps: "## agent-name" blocks
|
|
1608
|
+
const stepRegex = /##\s+(\([^)]+\)\s*\|[^\n]+|[^\n]+)/g;
|
|
1609
|
+
let match;
|
|
1610
|
+
while ((match = stepRegex.exec(body)) !== null) {
|
|
1611
|
+
const header = match[1]!.trim();
|
|
1612
|
+
// "## (web-agents.前端 | web-agents.后端)" parallel steps
|
|
1613
|
+
// "## web-agents.需求" single step
|
|
1614
|
+
// "## web-agents.测试" single step
|
|
1615
|
+
// Extract agent name(s) from header
|
|
1616
|
+
const parallelMatch = header.match(/^\(([^)]+)\)/);
|
|
1617
|
+
if (parallelMatch) {
|
|
1618
|
+
const agents = parallelMatch[1]!.split("|").map((s) => s.trim());
|
|
1619
|
+
agents.forEach((agent) => steps.push({ agent }));
|
|
1620
|
+
} else {
|
|
1621
|
+
steps.push({ agent: header });
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
return {
|
|
1626
|
+
name: frontmatter.name || fileName.replace(/\.chain\.md$/, ""),
|
|
1627
|
+
fileName,
|
|
1628
|
+
filePath,
|
|
1629
|
+
description: frontmatter.description || "",
|
|
1630
|
+
steps,
|
|
1631
|
+
body: raw.slice(0, 300),
|
|
1632
|
+
};
|
|
1633
|
+
} catch {
|
|
1634
|
+
return null;
|
|
1635
|
+
}
|
|
1636
|
+
}).filter(Boolean) as ChainDef[];
|
|
1637
|
+
} catch {
|
|
1638
|
+
return [];
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
export interface RunRecord {
|
|
1643
|
+
agent: string;
|
|
1644
|
+
ts: number;
|
|
1645
|
+
status: string;
|
|
1646
|
+
duration?: number;
|
|
1647
|
+
exit?: number;
|
|
1648
|
+
taskHash?: string;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
export function readRunHistory(limit = 100): RunRecord[] {
|
|
1652
|
+
try {
|
|
1653
|
+
if (!existsSync(RUN_HISTORY_PATH)) return [];
|
|
1654
|
+
const raw = readFileSync(RUN_HISTORY_PATH, "utf-8");
|
|
1655
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
1656
|
+
return lines
|
|
1657
|
+
.slice(-limit)
|
|
1658
|
+
.map((line) => {
|
|
1659
|
+
try {
|
|
1660
|
+
return JSON.parse(line) as RunRecord;
|
|
1661
|
+
} catch {
|
|
1662
|
+
return null;
|
|
1663
|
+
}
|
|
1664
|
+
})
|
|
1665
|
+
.filter((r): r is RunRecord => r !== null)
|
|
1666
|
+
.reverse();
|
|
1667
|
+
} catch {
|
|
1668
|
+
return [];
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
export interface SubagentsData {
|
|
1673
|
+
agents: AgentDef[];
|
|
1674
|
+
chains: ChainDef[];
|
|
1675
|
+
runHistory: RunRecord[];
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
export function readSubagents(): SubagentsData {
|
|
1679
|
+
return {
|
|
1680
|
+
agents: listAgents(),
|
|
1681
|
+
chains: listChains(),
|
|
1682
|
+
runHistory: readRunHistory(),
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// ─── Built-in Provider Catalog (from the local pi install) ───
|
|
1687
|
+
// pi ships its full model catalog (same source as pi.dev/models) inside
|
|
1688
|
+
// @earendil-works/pi-ai as dist/providers/data/*.json. Reading it locally
|
|
1689
|
+
// keeps the builtin provider list in sync with the installed pi version
|
|
1690
|
+
// instead of maintaining a hand-written copy.
|
|
1691
|
+
|
|
1692
|
+
interface CatalogModel {
|
|
1693
|
+
id: string;
|
|
1694
|
+
name?: string;
|
|
1695
|
+
reasoning?: boolean;
|
|
1696
|
+
input?: string[];
|
|
1697
|
+
contextWindow?: number;
|
|
1698
|
+
maxTokens?: number;
|
|
1699
|
+
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
interface CatalogProvider {
|
|
1703
|
+
id: string;
|
|
1704
|
+
name: string;
|
|
1705
|
+
type: "builtin";
|
|
1706
|
+
api?: string;
|
|
1707
|
+
baseUrl?: string;
|
|
1708
|
+
hasAuth: boolean;
|
|
1709
|
+
authMethod: "env";
|
|
1710
|
+
models: CatalogModel[];
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/** Locate @earendil-works/pi-ai's dist/providers directory of the active pi install. */
|
|
1714
|
+
function findPiAiProvidersDir(): string | null {
|
|
1715
|
+
const home = homedir();
|
|
1716
|
+
const roots: string[] = [];
|
|
1717
|
+
|
|
1718
|
+
// Resolve the pi binary symlink → .../pi-coding-agent/dist/cli.js
|
|
1719
|
+
const which = spawnSync("which", ["pi"], { encoding: "utf8", timeout: 5000 });
|
|
1720
|
+
const bin = which.status === 0 ? which.stdout.trim() : "";
|
|
1721
|
+
if (bin) {
|
|
1722
|
+
const real = spawnSync("readlink", ["-f", bin], { encoding: "utf8", timeout: 5000 });
|
|
1723
|
+
const cli = real.status === 0 ? real.stdout.trim() : "";
|
|
1724
|
+
if (cli) roots.push(resolve(dirname(cli), "..")); // package root
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// Known install locations as fallback
|
|
1728
|
+
const piNode = join(home, ".local", "share", "pi-node");
|
|
1729
|
+
try {
|
|
1730
|
+
for (const v of readdirSync(piNode)) {
|
|
1731
|
+
roots.push(join(piNode, v, "lib", "node_modules", PI_CORE_PACKAGE));
|
|
1732
|
+
}
|
|
1733
|
+
} catch {
|
|
1734
|
+
// pi-node dir absent
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
for (const root of roots) {
|
|
1738
|
+
const dir = join(root, "node_modules", "@earendil-works", "pi-ai", "dist", "providers");
|
|
1739
|
+
if (existsSync(join(dir, "data"))) return dir;
|
|
1740
|
+
}
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
let catalogCache: { providers: CatalogProvider[]; at: number } | null = null;
|
|
1745
|
+
|
|
1746
|
+
export function readBuiltinCatalog(): CatalogProvider[] | null {
|
|
1747
|
+
if (catalogCache && Date.now() - catalogCache.at < 300000) return catalogCache.providers;
|
|
1748
|
+
const dir = findPiAiProvidersDir();
|
|
1749
|
+
if (!dir) return null;
|
|
1750
|
+
|
|
1751
|
+
const providers: CatalogProvider[] = [];
|
|
1752
|
+
let files: string[];
|
|
1753
|
+
try {
|
|
1754
|
+
files = readdirSync(join(dir, "data")).filter(
|
|
1755
|
+
(f) => f.endsWith(".json") && !f.startsWith(".")
|
|
1756
|
+
);
|
|
1757
|
+
} catch {
|
|
1758
|
+
return null;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
for (const file of files) {
|
|
1762
|
+
const id = file.replace(/\.json$/, "");
|
|
1763
|
+
const data = readJsonFile<Record<string, Record<string, any>>>(join(dir, "data", file));
|
|
1764
|
+
if (!data) continue;
|
|
1765
|
+
|
|
1766
|
+
const models: CatalogModel[] = [];
|
|
1767
|
+
let baseUrl: string | undefined;
|
|
1768
|
+
let api: string | undefined;
|
|
1769
|
+
for (const apiKey of Object.keys(data)) {
|
|
1770
|
+
for (const m of Object.values(data[apiKey] ?? {})) {
|
|
1771
|
+
if (!m?.id) continue;
|
|
1772
|
+
baseUrl = baseUrl ?? m.baseUrl;
|
|
1773
|
+
api = api ?? m.api;
|
|
1774
|
+
models.push({
|
|
1775
|
+
id: m.id,
|
|
1776
|
+
name: m.name,
|
|
1777
|
+
reasoning: !!m.reasoning,
|
|
1778
|
+
input: Array.isArray(m.input) ? m.input : ["text"],
|
|
1779
|
+
contextWindow: m.contextWindow,
|
|
1780
|
+
maxTokens: m.maxTokens,
|
|
1781
|
+
cost: m.cost,
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
if (models.length === 0) continue;
|
|
1786
|
+
|
|
1787
|
+
// Display name lives in dist/providers/<id>.js: createProvider({ id: "…", name: "…" }).
|
|
1788
|
+
// Anchor on the id to avoid matching auth-method names like "Anthropic API key".
|
|
1789
|
+
let name = "";
|
|
1790
|
+
try {
|
|
1791
|
+
const src = readFileSync(join(dir, `${id}.js`), "utf-8");
|
|
1792
|
+
const esc = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1793
|
+
name =
|
|
1794
|
+
src.match(new RegExp(`id:\\s*"${esc}",\\s*name:\\s*"([^"]+)"`))?.[1] ??
|
|
1795
|
+
src.match(/createProvider\(\{[^}]*?name:\s*"([^"]+)"/)?.[1] ??
|
|
1796
|
+
"";
|
|
1797
|
+
} catch {
|
|
1798
|
+
// provider module absent — derive from id
|
|
1799
|
+
}
|
|
1800
|
+
if (!name) {
|
|
1801
|
+
name = id
|
|
1802
|
+
.split("-")
|
|
1803
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
1804
|
+
.join(" ");
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
providers.push({
|
|
1808
|
+
id,
|
|
1809
|
+
name,
|
|
1810
|
+
type: "builtin",
|
|
1811
|
+
api,
|
|
1812
|
+
baseUrl,
|
|
1813
|
+
hasAuth: false,
|
|
1814
|
+
authMethod: "env",
|
|
1815
|
+
models: models.sort((a, b) => a.id.localeCompare(b.id)),
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
if (providers.length === 0) return null;
|
|
1820
|
+
providers.sort((a, b) => a.id.localeCompare(b.id));
|
|
1821
|
+
catalogCache = { providers, at: Date.now() };
|
|
1822
|
+
return providers;
|
|
1823
|
+
}
|