pi-freeflow 1.1.3 → 1.1.4

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.
Files changed (2) hide show
  1. package/extensions/index.ts +164 -112
  2. package/package.json +1 -1
@@ -746,10 +746,7 @@ function checkRateLimit(ip: string, upstream: Upstream): boolean {
746
746
  return true;
747
747
  }
748
748
 
749
- // ── Health Check (OpenCode/Kilo catalogs; no per-model inference) ──
750
- // Each upstream publishes a model list; fetch it ONCE (cached) and check
751
- // membership. A 1-token chat probe per model was too slow (large models need
752
- // 10s+ for the first token). Real usability is validated at chat time (300s).
749
+ // ── Health Check & Dynamic Catalog Auto-Update ────────────────────
753
750
  const CATALOG_CACHE_FILE = path.join(
754
751
  homedir(),
755
752
  ".pi",
@@ -758,10 +755,98 @@ const CATALOG_CACHE_FILE = path.join(
758
755
  );
759
756
  const CATALOG_CACHE_TTL_MS = 3600_000; // 1 hour
760
757
 
758
+ function formatCleanDisplayName(id: string, customName?: string): string {
759
+ if (customName && customName.trim()) return customName.trim();
760
+ const known = MODEL_MAP.get(id);
761
+ if (known && known.name) return known.name;
762
+
763
+ // Strip provider prefix ("nvidia/", "stepfun/", "dots-studio/", etc.)
764
+ let clean = id.replace(/^[a-zA-Z0-9_.-]+\//, "");
765
+ // Strip variant suffixes
766
+ clean = clean.replace(/:(free|preview|exacto|default|batch)$/i, "");
767
+ clean = clean.replace(/-(free|contributor|preview)$/i, "");
768
+
769
+ // Capitalize words nicely
770
+ const parts = clean.split(/[-_]/).map((w) => {
771
+ const lower = w.toLowerCase();
772
+ if (lower === "gpt") return "GPT";
773
+ if (lower === "ai") return "AI";
774
+ if (lower === "lfm") return "LFM";
775
+ if (lower === "hy3") return "Hy3";
776
+ if (lower === "mimo") return "MiMo";
777
+ if (lower === "ocr") return "OCR";
778
+ return w.charAt(0).toUpperCase() + w.slice(1);
779
+ });
780
+
781
+ return parts.join(" ");
782
+ }
783
+
784
+ interface RawModelItem {
785
+ id: string;
786
+ context_length?: number;
787
+ max_output_tokens?: number;
788
+ [key: string]: unknown;
789
+ }
790
+
791
+ function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredModel {
792
+ const known = MODEL_MAP.get(raw.id);
793
+ if (known) return { ...known, source };
794
+
795
+ const idLower = raw.id.toLowerCase();
796
+ const hasVision =
797
+ idLower.includes("vision") ||
798
+ idLower.includes("vl") ||
799
+ idLower.includes("omni") ||
800
+ idLower.includes("note") ||
801
+ idLower.includes("image");
802
+ const hasReasoning =
803
+ idLower.includes("reasoning") ||
804
+ idLower.includes("r1") ||
805
+ idLower.includes("o1") ||
806
+ idLower.includes("think") ||
807
+ idLower.includes("alpha") ||
808
+ idLower.includes("spark");
809
+
810
+ let contextWindow =
811
+ typeof raw.context_length === "number" ? raw.context_length : 262_144;
812
+ if (
813
+ idLower.includes("1m") ||
814
+ idLower.includes("ultra") ||
815
+ idLower.includes("lightning") ||
816
+ idLower.includes("mimo-v2.5") ||
817
+ idLower.includes("muse-spark")
818
+ ) {
819
+ contextWindow = 1_048_576;
820
+ }
821
+
822
+ let maxTokens =
823
+ typeof raw.max_output_tokens === "number"
824
+ ? raw.max_output_tokens
825
+ : 65_536;
826
+ if (idLower.includes("ultra") || idLower.includes("lightning")) {
827
+ maxTokens = 131_072;
828
+ }
829
+
830
+ const isResponses = raw.id === "muse-spark-1.2-contributor-free";
831
+
832
+ return {
833
+ id: raw.id,
834
+ name: formatCleanDisplayName(raw.id),
835
+ source,
836
+ reasoning: hasReasoning,
837
+ contextWindow,
838
+ maxTokens,
839
+ api: isResponses ? "openai-responses" : undefined,
840
+ input: hasVision ? ["text", "image"] : ["text"],
841
+ thinkingFormat: source === "kilo" && hasReasoning ? "openrouter" : undefined,
842
+ };
843
+ }
844
+
761
845
  interface CatalogCacheData {
762
846
  timestamp: number;
763
847
  opencode: string[];
764
848
  kilo: string[];
849
+ models?: RegisteredModel[];
765
850
  }
766
851
 
767
852
  function readCatalogCache(): CatalogCacheData | null {
@@ -776,99 +861,81 @@ function readCatalogCache(): CatalogCacheData | null {
776
861
  return null;
777
862
  }
778
863
 
779
- function writeCatalogCache(opencode: string[], kilo: string[]): void {
864
+ async function refreshCatalog(force = false): Promise<RegisteredModel[]> {
865
+ if (!force) {
866
+ const disk = readCatalogCache();
867
+ if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
868
+ aliveCatalog = disk.models;
869
+ return aliveCatalog;
870
+ }
871
+ }
872
+
873
+ // 1. Fetch OpenCode Zen models
874
+ let opencodeList: RegisteredModel[] = [];
875
+ try {
876
+ const r = await fetch(`${API}/models`, {
877
+ headers: opencodeHeaders(),
878
+ signal: AbortSignal.timeout(10_000),
879
+ });
880
+ if (r.ok) {
881
+ const d = await r.json();
882
+ const items: RawModelItem[] = Array.isArray(d?.data) ? d.data : [];
883
+ const aliveIds = new Set(items.map((m) => m.id));
884
+ opencodeList = KNOWN_MODELS.filter((m) => aliveIds.has(m.id)).map((m) => ({
885
+ ...m,
886
+ source: "opencode" as const,
887
+ }));
888
+ }
889
+ } catch {}
890
+ if (!opencodeList.length) {
891
+ opencodeList = KNOWN_MODELS.map((m) => ({ ...m, source: "opencode" as const }));
892
+ }
893
+
894
+ // 2. Fetch KiloCode Gateway models
895
+ let kiloList: RegisteredModel[] = [];
896
+ try {
897
+ const r = await fetch(
898
+ KILO_CHAT_URL.replace("/chat/completions", "/models"),
899
+ {
900
+ headers: { Authorization: "Bearer kilo-free" },
901
+ signal: AbortSignal.timeout(10_000),
902
+ },
903
+ );
904
+ if (r.ok) {
905
+ const d = await r.json();
906
+ const items: RawModelItem[] = Array.isArray(d?.data) ? d.data : [];
907
+ const aliveIds = new Set(items.map((m) => m.id));
908
+ kiloList = KILO_MODELS.filter((m) => aliveIds.has(m.id)).map((m) => ({
909
+ ...m,
910
+ source: "kilo" as const,
911
+ }));
912
+ }
913
+ } catch {}
914
+ if (!kiloList.length) {
915
+ kiloList = KILO_MODELS.map((m) => ({ ...m, source: "kilo" as const }));
916
+ }
917
+
918
+ const all = [...opencodeList, ...kiloList];
919
+ aliveCatalog = all;
920
+
921
+ // Write rich models to cache atomically
780
922
  try {
781
923
  const dir = path.dirname(CATALOG_CACHE_FILE);
782
924
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
783
925
  const data: CatalogCacheData = {
784
926
  timestamp: Date.now(),
785
- opencode,
786
- kilo,
927
+ opencode: opencodeList.map((m) => m.id),
928
+ kilo: kiloList.map((m) => m.id),
929
+ models: all,
787
930
  };
788
931
  const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
789
- fs.writeFileSync(tmpPath, JSON.stringify(data), "utf8");
932
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
790
933
  fs.renameSync(tmpPath, CATALOG_CACHE_FILE);
791
934
  } catch {}
792
- }
793
-
794
- let opencodeCatalogP: Promise<Set<string> | null> | null = null;
795
- function opencodeCatalog(): Promise<Set<string> | null> {
796
- if (!opencodeCatalogP)
797
- opencodeCatalogP = (async () => {
798
- const disk = readCatalogCache();
799
- if (disk && Array.isArray(disk.opencode) && disk.opencode.length > 0) {
800
- return new Set<string>(disk.opencode);
801
- }
802
- try {
803
- const r = await fetch(`${API}/models`, {
804
- headers: opencodeHeaders(),
805
- signal: AbortSignal.timeout(10_000),
806
- });
807
- if (!r.ok) {
808
- // Fallback to static known models if network fails
809
- return new Set<string>(KNOWN_MODELS.map((m) => m.id));
810
- }
811
- const d = await r.json();
812
- const arr = (d?.data ?? []).map((m: { id: string }) => m.id);
813
- const s = new Set<string>(arr);
814
- const kDisk = disk?.kilo ?? KILO_MODELS.map((m) => m.id);
815
- writeCatalogCache(arr, kDisk);
816
- return s;
817
- } catch {
818
- return new Set<string>(KNOWN_MODELS.map((m) => m.id));
819
- }
820
- })();
821
- return opencodeCatalogP;
822
- }
823
- let kiloCatalogP: Promise<Set<string> | null> | null = null;
824
- function kiloCatalog(): Promise<Set<string> | null> {
825
- if (!kiloCatalogP)
826
- kiloCatalogP = (async () => {
827
- const disk = readCatalogCache();
828
- if (disk && Array.isArray(disk.kilo) && disk.kilo.length > 0) {
829
- return new Set<string>(disk.kilo);
830
- }
831
- try {
832
- const r = await fetch(
833
- KILO_CHAT_URL.replace("/chat/completions", "/models"),
834
- {
835
- headers: { Authorization: "Bearer kilo-free" },
836
- signal: AbortSignal.timeout(10_000),
837
- },
838
- );
839
- if (!r.ok) {
840
- return new Set<string>(KILO_MODELS.map((m) => m.id));
841
- }
842
- const d = await r.json();
843
- const arr = (d?.data ?? []).map((m: { id: string }) => m.id);
844
- const s = new Set<string>(arr);
845
- const ocDisk = disk?.opencode ?? KNOWN_MODELS.map((m) => m.id);
846
- writeCatalogCache(ocDisk, arr);
847
- return s;
848
- } catch {
849
- return new Set<string>(KILO_MODELS.map((m) => m.id));
850
- }
851
- })();
852
- return kiloCatalogP;
853
- }
854
935
 
855
- async function checkModelAlive(id: string): Promise<boolean> {
856
- try {
857
- const cat = await opencodeCatalog();
858
- return cat ? cat.has(id) : false;
859
- } catch {
860
- return false;
861
- }
936
+ return all;
862
937
  }
863
938
 
864
- async function checkKiloAlive(id: string): Promise<boolean> {
865
- try {
866
- const cat = await kiloCatalog();
867
- return cat ? cat.has(id) : false;
868
- } catch {
869
- return false;
870
- }
871
- }
872
939
 
873
940
  // ── Helpers ────────────────────────────────────────────────────────
874
941
  function getClientIP(req: http.IncomingMessage): string {
@@ -1371,31 +1438,8 @@ export default async function (pi: ExtensionAPI) {
1371
1438
  return;
1372
1439
  }
1373
1440
  }
1374
- // Health check opencode models
1375
- log("info", `checking ${KNOWN_MODELS.length} opencode model(s)...`);
1376
- const opencodeChecks = await Promise.all(
1377
- KNOWN_MODELS.map(async (model) => {
1378
- const alive = await checkModelAlive(model.id);
1379
- if (alive) log("info", `✓ ${model.id} is alive`);
1380
- else log("warn", `✗ ${model.id} is dead — skipping`);
1381
- return { ...model, alive, source: "opencode" as const };
1382
- }),
1383
- );
1384
-
1385
- // Health check kilo models
1386
- log("info", `checking ${KILO_MODELS.length} kilo model(s)...`);
1387
- const kiloChecks = await Promise.all(
1388
- KILO_MODELS.map(async (model) => {
1389
- const alive = await checkKiloAlive(model.id);
1390
- if (alive) log("info", `✓ ${model.id} (kilo) is alive`);
1391
- else log("warn", `✗ ${model.id} (kilo) is dead — skipping`);
1392
- return { ...model, alive, source: "kilo" as const };
1393
- }),
1394
- );
1395
-
1396
- const aliveModels = [...opencodeChecks, ...kiloChecks].filter((m) => m.alive);
1441
+ const aliveModels = await refreshCatalog();
1397
1442
  aliveCatalog = aliveModels;
1398
-
1399
1443
  if (aliveModels.length === 0) {
1400
1444
  // Don't bail: still register /bansos below so the user can recover
1401
1445
  // (e.g. switch the relay off) instead of being stranded with no command.
@@ -1439,9 +1483,9 @@ export default async function (pi: ExtensionAPI) {
1439
1483
  // ── /bansos command: toggle relay egress live (on|off|status|url [URL]) ───
1440
1484
  const commandSpec = {
1441
1485
  description:
1442
- "Relay egress: on | off | status | logs | url [URL] | deploy | list | use <URL> | remove <URL>",
1486
+ "Relay egress: on | off | status | logs | url [URL] | deploy | list | use <URL> | remove <URL> | refresh",
1443
1487
  getArgumentCompletions: (prefix: string) =>
1444
- ["on", "off", "status", "url", "deploy", "list", "use", "remove"]
1488
+ ["on", "off", "status", "url", "deploy", "list", "use", "remove", "refresh", "models"]
1445
1489
  .filter((s) => s.startsWith(prefix))
1446
1490
  .map((s) => ({ value: s, label: s })),
1447
1491
  handler: async (args: string, ctx) => {
@@ -1582,7 +1626,7 @@ export default async function (pi: ExtensionAPI) {
1582
1626
  } else if (sub === "logs" || sub === "log") {
1583
1627
  try {
1584
1628
  if (!fs.existsSync(LOG_FILE)) {
1585
- ctx.ui.notify(`No logs recorded yet in ${LOG_FILE}`, "info");
1629
+ ctx.ui.notify("Log file is empty", "info");
1586
1630
  return;
1587
1631
  }
1588
1632
  const content = fs.readFileSync(LOG_FILE, "utf8");
@@ -1591,6 +1635,14 @@ export default async function (pi: ExtensionAPI) {
1591
1635
  } catch (e) {
1592
1636
  ctx.ui.notify(`Could not read log file: ${(e as Error).message}`, "error");
1593
1637
  }
1638
+ } else if (sub === "refresh" || sub === "reload" || sub === "models") {
1639
+ ctx.ui.notify("Refreshing model catalog from live upstreams…", "info");
1640
+ const updated = await refreshCatalog(true);
1641
+ persist();
1642
+ ctx.ui.notify(
1643
+ `✓ Refreshed ${updated.length} models with full-spec metadata!`,
1644
+ "info",
1645
+ );
1594
1646
  } else if (sub === "remove") {
1595
1647
  const url = (
1596
1648
  rest ||
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.1.3",
4
+ "version": "1.1.4",
5
5
  "description": "Personal multi-cloud rolling fallback relay for OpenCode Zen and KiloCode models in OMP/Pi",
6
6
  "keywords": [
7
7
  "pi-package",