pi-freeflow 1.1.3 → 1.1.5
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.md +137 -23
- package/extensions/index.ts +164 -112
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -94,10 +94,18 @@ omp plugin link /path/to/pi-freeflow
|
|
|
94
94
|
|
|
95
95
|
---
|
|
96
96
|
|
|
97
|
-
## 🌐 Setting Up Your Own Egress Relays
|
|
97
|
+
## 🌐 Setting Up Your Own Egress Relays (Multi-Cloud)
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
By default, FreeFlow works out of the box with direct upstream calls. To expand concurrency, bypass IP rate limits, and enable rolling failover, you can connect your own free cloud relays across **Cloudflare Workers**, **Vercel Edge**, **Deno Deploy**, or a **Linux VPS**.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
### 1. Cloudflare Worker Relay (Recommended ⭐)
|
|
104
|
+
> **Free Tier**: 100,000 requests/day · Low latency · No 25-second execution timeout.
|
|
105
|
+
|
|
106
|
+
1. Go to [dash.cloudflare.com](https://dash.cloudflare.com) $\to$ **Workers & Pages** $\to$ **Create application** $\to$ **Create Worker**.
|
|
107
|
+
2. Name your worker (e.g. `my-freeflow-relay`) $\to$ click **Deploy**.
|
|
108
|
+
3. Click **Edit code**, paste this JavaScript, and click **Save and Deploy**:
|
|
101
109
|
|
|
102
110
|
```javascript
|
|
103
111
|
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
@@ -108,8 +116,9 @@ export default {
|
|
|
108
116
|
return new Response(null, {
|
|
109
117
|
headers: {
|
|
110
118
|
"Access-Control-Allow-Origin": "*",
|
|
111
|
-
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
119
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, HEAD",
|
|
112
120
|
"Access-Control-Allow-Headers": "*",
|
|
121
|
+
"Access-Control-Max-Age": "86400",
|
|
113
122
|
},
|
|
114
123
|
});
|
|
115
124
|
}
|
|
@@ -117,8 +126,8 @@ export default {
|
|
|
117
126
|
const target = request.headers.get("x-relay-target");
|
|
118
127
|
const relayPath = request.headers.get("x-relay-path") || "/";
|
|
119
128
|
|
|
120
|
-
if (!target || !ALLOWED_TARGETS.includes(target)) {
|
|
121
|
-
return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403 });
|
|
129
|
+
if (!target || !ALLOWED_TARGETS.includes(target.replace(/\/$/, ""))) {
|
|
130
|
+
return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
const targetUrl = target.replace(/\/$/, "") + relayPath;
|
|
@@ -126,42 +135,147 @@ export default {
|
|
|
126
135
|
headers.delete("x-relay-target");
|
|
127
136
|
headers.delete("x-relay-path");
|
|
128
137
|
headers.delete("host");
|
|
138
|
+
headers.delete("cf-connecting-ip");
|
|
139
|
+
headers.delete("cf-ray");
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const response = await fetch(targetUrl, {
|
|
143
|
+
method: request.method,
|
|
144
|
+
headers,
|
|
145
|
+
body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined,
|
|
146
|
+
});
|
|
129
147
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
headers
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
const outHeaders = new Headers(response.headers);
|
|
149
|
+
outHeaders.set("Access-Control-Allow-Origin", "*");
|
|
150
|
+
return new Response(response.body, { status: response.status, headers: outHeaders });
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return new Response(JSON.stringify({ error: "Upstream failed", details: String(err) }), { status: 502, headers: { "content-type": "application/json" } });
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
```
|
|
135
157
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
158
|
+
4. Copy your Worker URL and register it inside OMP:
|
|
159
|
+
```text
|
|
160
|
+
/freeflow use https://my-freeflow-relay.your-subdomain.workers.dev
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
### 2. Vercel Edge Relay
|
|
166
|
+
> **Free Tier**: 1,000,000 requests/month.
|
|
167
|
+
|
|
168
|
+
#### Method A: Automated In-Memory CLI Deploy
|
|
169
|
+
1. Generate a Vercel token at [vercel.com/account/tokens](https://vercel.com/account/tokens).
|
|
170
|
+
2. In OMP TUI, run:
|
|
142
171
|
```text
|
|
143
172
|
/freeflow deploy
|
|
144
173
|
```
|
|
145
|
-
*(Prompts for your
|
|
174
|
+
*(Prompts for your token in-memory, provisions a private Edge Function, and adds it to your relay pool automatically).*
|
|
175
|
+
|
|
176
|
+
#### Method B: Manual Git Deploy
|
|
177
|
+
Deploy a Git repository with these 3 files:
|
|
178
|
+
* **`api/relay.js`**:
|
|
179
|
+
```javascript
|
|
180
|
+
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
181
|
+
export const config = { runtime: "edge" };
|
|
182
|
+
export default async function handler(req) {
|
|
183
|
+
const target = req.headers.get("x-relay-target");
|
|
184
|
+
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
185
|
+
if (!target || !ALLOWED_TARGETS.includes(target.replace(/\/$/, ""))) return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 });
|
|
186
|
+
const targetUrl = target.replace(/\/$/, "") + relayPath;
|
|
187
|
+
const headers = new Headers(req.headers);
|
|
188
|
+
headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
|
|
189
|
+
const response = await fetch(targetUrl, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half" });
|
|
190
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
* **`package.json`**: `{ "name": "freeflow-relay", "version": "1.0.0" }`
|
|
194
|
+
* **`vercel.json`**: `{ "rewrites": [{ "source": "/(.*)", "destination": "/api/relay" }] }`
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
### 3. Deno Deploy Relay
|
|
199
|
+
> **Free Tier**: 100,000 requests/day.
|
|
200
|
+
|
|
201
|
+
1. Open [dash.deno.com](https://dash.deno.com) $\to$ **New Project** $\to$ **Playground**.
|
|
202
|
+
2. Paste the following TypeScript code:
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
206
|
+
|
|
207
|
+
Deno.serve(async (req) => {
|
|
208
|
+
if (req.method === "OPTIONS") {
|
|
209
|
+
return new Response(null, {
|
|
210
|
+
headers: {
|
|
211
|
+
"Access-Control-Allow-Origin": "*",
|
|
212
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
213
|
+
"Access-Control-Allow-Headers": "*",
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const target = req.headers.get("x-relay-target");
|
|
219
|
+
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
220
|
+
if (!target || !ALLOWED_TARGETS.includes(target.replace(/\/$/, ""))) {
|
|
221
|
+
return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403 });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const targetUrl = target.replace(/\/$/, "") + relayPath;
|
|
225
|
+
const headers = new Headers(req.headers);
|
|
226
|
+
headers.delete("x-relay-target");
|
|
227
|
+
headers.delete("x-relay-path");
|
|
228
|
+
headers.delete("host");
|
|
229
|
+
|
|
230
|
+
const response = await fetch(targetUrl, {
|
|
231
|
+
method: req.method,
|
|
232
|
+
headers,
|
|
233
|
+
body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined,
|
|
234
|
+
});
|
|
235
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
3. Click **Deploy** and copy your Deno project URL (`https://project-name.deno.dev`).
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## 🎮 Managing Relays in FreeFlow
|
|
243
|
+
|
|
244
|
+
Inside the OMP / Pi TUI:
|
|
245
|
+
```text
|
|
246
|
+
/freeflow use <URL> # Set active relay and add to saved pool
|
|
247
|
+
/freeflow list # View all saved relays with status indicators (★ = active)
|
|
248
|
+
/freeflow status # Check current relay health and egress mode
|
|
249
|
+
/freeflow refresh # Trigger live on-demand model catalog sync from upstream APIs
|
|
250
|
+
/freeflow on # Force enable relay egress mode
|
|
251
|
+
/freeflow off # Switch to direct upstream mode
|
|
252
|
+
/freeflow remove <URL> # Remove an inactive relay from pool
|
|
253
|
+
/freeflow logs # Show last 25 lines of debug logs
|
|
254
|
+
```
|
|
146
255
|
|
|
147
256
|
---
|
|
148
257
|
|
|
149
258
|
## 🛠️ Diagnostics & Troubleshooting
|
|
150
259
|
|
|
151
260
|
### Custom Port Configuration
|
|
152
|
-
By default, `pi-freeflow` uses a **Single Shared Port** (`18080`). All concurrent sub-agents automatically
|
|
261
|
+
By default, `pi-freeflow` uses a **Single Shared Master Port** (`18080`). All concurrent sub-agents automatically reuse this master proxy without spawning redundant servers or extra sockets.
|
|
153
262
|
|
|
154
263
|
To change the base port:
|
|
155
264
|
```env
|
|
156
265
|
FREEFLOW_PORT=19000
|
|
157
266
|
```
|
|
158
267
|
|
|
159
|
-
|
|
268
|
+
### Subagent Credentials
|
|
269
|
+
If subagents report missing provider keys, verify `~/.omp/agent/.env`:
|
|
270
|
+
```env
|
|
271
|
+
FREEFLOW_API_KEY=freeflow
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Live Logs
|
|
160
275
|
```bash
|
|
161
|
-
cat ~/.pi/agent/pi-freeflow.log | tail -n
|
|
162
|
-
# or inside OMP:
|
|
163
|
-
/freeflow logs
|
|
276
|
+
cat ~/.pi/agent/pi-freeflow.log | tail -n 40
|
|
164
277
|
```
|
|
278
|
+
|
|
165
279
|
---
|
|
166
280
|
|
|
167
281
|
## 📄 License
|
package/extensions/index.ts
CHANGED
|
@@ -746,10 +746,7 @@ function checkRateLimit(ip: string, upstream: Upstream): boolean {
|
|
|
746
746
|
return true;
|
|
747
747
|
}
|
|
748
748
|
|
|
749
|
-
// ── Health Check
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 ||
|