pi-freeflow 1.2.0 → 1.3.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/LICENSE +21 -0
- package/README.md +7 -11
- package/extensions/index.ts +4 -2137
- package/package.json +30 -5
- package/src/catalog.ts +204 -0
- package/src/commands.ts +448 -0
- package/src/config.ts +145 -0
- package/src/deploy.ts +126 -0
- package/src/index.ts +244 -0
- package/src/logger.ts +327 -0
- package/src/models.ts +343 -0
- package/src/proxy.ts +473 -0
- package/src/rate-limiter.ts +148 -0
- package/src/relay-state.ts +218 -0
- package/src/relay.ts +193 -0
- package/src/stream-pipe.ts +154 -0
- package/src/types.ts +164 -0
package/package.json
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "1.3.0",
|
|
5
|
+
"description": "Multi-cloud rolling fallback relay for OpenCode Zen and KiloCode free models in Oh My Pi (OMP) and Pi",
|
|
6
|
+
"main": "extensions/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
6
8
|
"keywords": [
|
|
7
9
|
"pi-package",
|
|
8
10
|
"pi-extension",
|
|
9
|
-
"
|
|
11
|
+
"oh-my-pi",
|
|
12
|
+
"omp",
|
|
13
|
+
"free-models",
|
|
14
|
+
"opencode",
|
|
15
|
+
"kilocode",
|
|
16
|
+
"ai-models",
|
|
17
|
+
"relay"
|
|
10
18
|
],
|
|
11
19
|
"author": "trefeon",
|
|
12
20
|
"license": "MIT",
|
|
@@ -15,6 +23,11 @@
|
|
|
15
23
|
"url": "https://github.com/trefeon/pi-freeflow.git"
|
|
16
24
|
},
|
|
17
25
|
"homepage": "https://github.com/trefeon/pi-freeflow#readme",
|
|
26
|
+
"omp": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./extensions"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
18
31
|
"pi": {
|
|
19
32
|
"extensions": [
|
|
20
33
|
"./extensions"
|
|
@@ -22,6 +35,18 @@
|
|
|
22
35
|
},
|
|
23
36
|
"files": [
|
|
24
37
|
"extensions",
|
|
25
|
-
"
|
|
26
|
-
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node --experimental-strip-types --test test/**/*.test.ts",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\""
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
49
|
+
"@types/node": "^22.13.9",
|
|
50
|
+
"typescript": "^5.8.2"
|
|
51
|
+
}
|
|
27
52
|
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic catalog discovery, caching, and model enrichment for pi-freeflow
|
|
3
|
+
*
|
|
4
|
+
* Provides 1-hour atomic disk caching with safe offline fallback to verified static definitions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
CATALOG_CACHE_FILE,
|
|
12
|
+
CATALOG_CACHE_TTL_MS,
|
|
13
|
+
KILO_CHAT_URL,
|
|
14
|
+
OPENCODE_API_URL,
|
|
15
|
+
opencodeHeaders,
|
|
16
|
+
} from "./config.ts";
|
|
17
|
+
import { log, logDebug, logWarn } from "./logger.ts";
|
|
18
|
+
import {
|
|
19
|
+
KILO_MODELS,
|
|
20
|
+
KNOWN_MODELS,
|
|
21
|
+
MODEL_MAP,
|
|
22
|
+
OPENCODE_MODELS,
|
|
23
|
+
} from "./models.ts";
|
|
24
|
+
import type {
|
|
25
|
+
CatalogCacheData,
|
|
26
|
+
RawModelItem,
|
|
27
|
+
RegisteredModel,
|
|
28
|
+
Upstream,
|
|
29
|
+
} from "./types.ts";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* In-memory cache of currently active/available free models.
|
|
33
|
+
* Initialized with all 23 verified models for 0ms instant availability.
|
|
34
|
+
*/
|
|
35
|
+
let aliveCatalog: RegisteredModel[] = [
|
|
36
|
+
...OPENCODE_MODELS.map((m) => ({ ...m, source: "opencode" as const })),
|
|
37
|
+
...KILO_MODELS.map((m) => ({ ...m, source: "kilo" as const })),
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* Get current in-memory alive catalog
|
|
41
|
+
*/
|
|
42
|
+
export function getAliveCatalog(): RegisteredModel[] {
|
|
43
|
+
return aliveCatalog;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Set in-memory alive catalog
|
|
48
|
+
*/
|
|
49
|
+
export function setAliveCatalog(catalog: RegisteredModel[]): void {
|
|
50
|
+
aliveCatalog = catalog;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Format a clean, human-readable display name for any upstream model ID.
|
|
55
|
+
*/
|
|
56
|
+
export function formatCleanDisplayName(id: string, customName?: string): string {
|
|
57
|
+
if (customName && customName.trim()) {
|
|
58
|
+
return customName.trim();
|
|
59
|
+
}
|
|
60
|
+
const known = MODEL_MAP.get(id);
|
|
61
|
+
if (known && known.name) {
|
|
62
|
+
return known.name;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Strip provider prefix ("nvidia/", "stepfun/", "dots-studio/", etc.)
|
|
66
|
+
let clean = id.replace(/^[a-zA-Z0-9_.-]+\//, "");
|
|
67
|
+
// Strip variant suffixes
|
|
68
|
+
clean = clean.replace(/:(free|preview|exacto|default|batch)$/i, "");
|
|
69
|
+
clean = clean.replace(/-(free|contributor|preview)$/i, "");
|
|
70
|
+
|
|
71
|
+
// Capitalize words with acronym preservation
|
|
72
|
+
const parts = clean.split(/[-_]/).map((w) => {
|
|
73
|
+
const lower = w.toLowerCase();
|
|
74
|
+
if (lower === "gpt") return "GPT";
|
|
75
|
+
if (lower === "ai") return "AI";
|
|
76
|
+
if (lower === "lfm") return "LFM";
|
|
77
|
+
if (lower === "hy3") return "Hy3";
|
|
78
|
+
if (lower === "mimo") return "MiMo";
|
|
79
|
+
if (lower === "ocr") return "OCR";
|
|
80
|
+
return w.charAt(0).toUpperCase() + w.slice(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return parts.join(" ");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Enrich a raw upstream model item into a fully typed RegisteredModel.
|
|
88
|
+
*/
|
|
89
|
+
export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredModel {
|
|
90
|
+
const known = MODEL_MAP.get(raw.id);
|
|
91
|
+
if (known) {
|
|
92
|
+
return { ...known, source };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const idLower = raw.id.toLowerCase();
|
|
96
|
+
const hasVision =
|
|
97
|
+
idLower.includes("vision") ||
|
|
98
|
+
idLower.includes("vl") ||
|
|
99
|
+
idLower.includes("omni") ||
|
|
100
|
+
idLower.includes("note") ||
|
|
101
|
+
idLower.includes("image");
|
|
102
|
+
const hasReasoning =
|
|
103
|
+
idLower.includes("reasoning") ||
|
|
104
|
+
idLower.includes("r1") ||
|
|
105
|
+
idLower.includes("o1") ||
|
|
106
|
+
idLower.includes("think") ||
|
|
107
|
+
idLower.includes("alpha") ||
|
|
108
|
+
idLower.includes("spark");
|
|
109
|
+
|
|
110
|
+
let contextWindow =
|
|
111
|
+
typeof raw.context_length === "number" ? raw.context_length : 262_144;
|
|
112
|
+
if (
|
|
113
|
+
idLower.includes("1m") ||
|
|
114
|
+
idLower.includes("ultra") ||
|
|
115
|
+
idLower.includes("lightning") ||
|
|
116
|
+
idLower.includes("mimo-v2.5") ||
|
|
117
|
+
idLower.includes("muse-spark")
|
|
118
|
+
) {
|
|
119
|
+
contextWindow = 1_048_576;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let maxTokens =
|
|
123
|
+
typeof raw.max_output_tokens === "number"
|
|
124
|
+
? raw.max_output_tokens
|
|
125
|
+
: 65_536;
|
|
126
|
+
if (idLower.includes("ultra") || idLower.includes("lightning")) {
|
|
127
|
+
maxTokens = 131_072;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const isResponses = raw.id === "muse-spark-1.2-contributor-free";
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
id: raw.id,
|
|
134
|
+
name: formatCleanDisplayName(raw.id),
|
|
135
|
+
source,
|
|
136
|
+
reasoning: hasReasoning,
|
|
137
|
+
contextWindow,
|
|
138
|
+
maxTokens,
|
|
139
|
+
api: isResponses ? "openai-responses" : undefined,
|
|
140
|
+
input: hasVision ? ["text", "image"] : ["text"],
|
|
141
|
+
thinkingFormat: source === "kilo" && hasReasoning ? "openrouter" : undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Read cached catalog data from disk if valid and unexpired.
|
|
147
|
+
*/
|
|
148
|
+
export function readCatalogCache(): CatalogCacheData | null {
|
|
149
|
+
try {
|
|
150
|
+
if (!fs.existsSync(CATALOG_CACHE_FILE)) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
|
|
154
|
+
const data = JSON.parse(raw) as CatalogCacheData;
|
|
155
|
+
if (Date.now() - data.timestamp < CATALOG_CACHE_TTL_MS) {
|
|
156
|
+
return data;
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
logDebug("Failed reading catalog cache", { error: String(err) });
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Atomically write catalog cache data to disk using temporary file + rename.
|
|
166
|
+
*/
|
|
167
|
+
export function writeCatalogCache(data: CatalogCacheData): void {
|
|
168
|
+
try {
|
|
169
|
+
const dir = path.dirname(CATALOG_CACHE_FILE);
|
|
170
|
+
if (!fs.existsSync(dir)) {
|
|
171
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
172
|
+
}
|
|
173
|
+
const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
|
|
174
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
|
|
175
|
+
fs.renameSync(tmpPath, CATALOG_CACHE_FILE);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
logWarn("Could not persist catalog cache to disk", { error: String(err) });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Refresh free model catalog from OpenCode Zen and KiloCode Gateway endpoints.
|
|
183
|
+
* Falls back gracefully to cached or static models if network requests fail.
|
|
184
|
+
*/
|
|
185
|
+
export async function refreshCatalog(force = false): Promise<RegisteredModel[]> {
|
|
186
|
+
// Thin provider: no live fetch — subagents must not hit upstream directly
|
|
187
|
+
// (proxy-only). Host Pi/OMP owns dynamic discovery via fetchDynamicModels (24h).
|
|
188
|
+
// We only serve disk cache if fresh, otherwise static 23-model aliveCatalog.
|
|
189
|
+
const disk = readCatalogCache();
|
|
190
|
+
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
191
|
+
const age = Date.now() - (disk.timestamp ?? 0);
|
|
192
|
+
if (!force && age < CATALOG_CACHE_TTL_MS) {
|
|
193
|
+
aliveCatalog = disk.models;
|
|
194
|
+
return aliveCatalog;
|
|
195
|
+
}
|
|
196
|
+
// Stale cache still better than empty — return it without network
|
|
197
|
+
if (disk.models.length === 23) {
|
|
198
|
+
aliveCatalog = disk.models;
|
|
199
|
+
return aliveCatalog;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// No valid cache — return in-memory static 23 (host will refresh if needed)
|
|
203
|
+
return aliveCatalog;
|
|
204
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive CLI slash commands and status bar manager for pi-freeflow
|
|
3
|
+
*
|
|
4
|
+
* log viewing, debug level configuration, and live catalog refreshing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
|
|
8
|
+
import { DEBUG_STATE_FILE, DEFAULT_RELAY_URL, LOG_FILE } from "./config.ts";
|
|
9
|
+
import { deployVercelRelay } from "./deploy.ts";
|
|
10
|
+
import {
|
|
11
|
+
LOG_LEVEL_ORDER,
|
|
12
|
+
getMinLogLevel,
|
|
13
|
+
isDebugEnabled,
|
|
14
|
+
loadDebugState,
|
|
15
|
+
readRecentLogs,
|
|
16
|
+
saveDebugState,
|
|
17
|
+
} from "./logger.ts";
|
|
18
|
+
import {
|
|
19
|
+
ensureRelay,
|
|
20
|
+
getActiveRelayState,
|
|
21
|
+
removeRelay,
|
|
22
|
+
saveRelayState,
|
|
23
|
+
setActiveRelayState,
|
|
24
|
+
setStatusUi,
|
|
25
|
+
shortRelayLabel,
|
|
26
|
+
} from "./relay-state.ts";
|
|
27
|
+
import type {
|
|
28
|
+
ExtensionAPI,
|
|
29
|
+
ExtensionContext,
|
|
30
|
+
ExtensionUIContext,
|
|
31
|
+
KnownRelay,
|
|
32
|
+
LogLevel,
|
|
33
|
+
RegisteredCommand,
|
|
34
|
+
RegisteredModel,
|
|
35
|
+
RelayState,
|
|
36
|
+
} from "./types.ts";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Update the TUI status bar widget with active relay state.
|
|
40
|
+
*/
|
|
41
|
+
export function updateStatusBar(ui?: ExtensionUIContext): void {
|
|
42
|
+
if (!ui) return;
|
|
43
|
+
const relayState = getActiveRelayState();
|
|
44
|
+
if (relayState.enabled && relayState.relays.length > 0) {
|
|
45
|
+
const label = shortRelayLabel(relayState.url);
|
|
46
|
+
const idx = Math.max(
|
|
47
|
+
1,
|
|
48
|
+
relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
|
|
49
|
+
);
|
|
50
|
+
const total = relayState.relays.length;
|
|
51
|
+
ui.setStatus("freeflow", `relay: ON | ${label} ${idx}/${total}`);
|
|
52
|
+
} else {
|
|
53
|
+
ui.setStatus("freeflow", undefined);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createCommandSpec(
|
|
58
|
+
_pi: ExtensionAPI,
|
|
59
|
+
onCatalogRefreshed?: (models: RegisteredModel[]) => void,
|
|
60
|
+
): Omit<RegisteredCommand, "name"> {
|
|
61
|
+
return {
|
|
62
|
+
description:
|
|
63
|
+
"Relay egress: on | off | status | logs [level] [n] | debug on|off|status | url [URL] | deploy | list | use <URL> | remove <URL> | refresh",
|
|
64
|
+
getArgumentCompletions: (prefix: string) =>
|
|
65
|
+
[
|
|
66
|
+
"on",
|
|
67
|
+
"off",
|
|
68
|
+
"status",
|
|
69
|
+
"url",
|
|
70
|
+
"deploy",
|
|
71
|
+
"list",
|
|
72
|
+
"use",
|
|
73
|
+
"remove",
|
|
74
|
+
"refresh",
|
|
75
|
+
"models",
|
|
76
|
+
"logs",
|
|
77
|
+
"debug",
|
|
78
|
+
"trace",
|
|
79
|
+
]
|
|
80
|
+
.filter((s) => s.startsWith(prefix))
|
|
81
|
+
.map((s) => ({ value: s, label: s })),
|
|
82
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
83
|
+
const parts = String(args || "")
|
|
84
|
+
.trim()
|
|
85
|
+
.split(/\s+/);
|
|
86
|
+
const sub = parts[0] || "";
|
|
87
|
+
const rest = parts.slice(1).join(" ");
|
|
88
|
+
const relayState = getActiveRelayState();
|
|
89
|
+
|
|
90
|
+
const flash = () => {
|
|
91
|
+
const activeLabel = shortRelayLabel(relayState.url);
|
|
92
|
+
const activeIdx = Math.max(
|
|
93
|
+
1,
|
|
94
|
+
relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
|
|
95
|
+
);
|
|
96
|
+
const total = relayState.relays.length || 1;
|
|
97
|
+
ctx.ui.notify(
|
|
98
|
+
`Relay ${relayState.enabled ? "ON" : "OFF"}${relayState.enabled ? ` → ${activeLabel} (${activeIdx}/${total})` : " (direct)"} | saved=${relayState.relays.length} (auto-fallback rolling)`,
|
|
99
|
+
"info",
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const persist = () => {
|
|
104
|
+
saveRelayState(relayState);
|
|
105
|
+
setStatusUi(ctx.ui);
|
|
106
|
+
updateStatusBar(ctx.ui);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const setRelay = (
|
|
110
|
+
enabled: boolean,
|
|
111
|
+
url: string,
|
|
112
|
+
addLabel?: string,
|
|
113
|
+
) => {
|
|
114
|
+
relayState.enabled = enabled;
|
|
115
|
+
relayState.url = (url || "").trim() || DEFAULT_RELAY_URL;
|
|
116
|
+
if (relayState.url) {
|
|
117
|
+
ensureRelay(relayState, relayState.url, addLabel);
|
|
118
|
+
}
|
|
119
|
+
setActiveRelayState(relayState);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const doDeploy = async () => {
|
|
123
|
+
const defaultName = `relay-${Date.now().toString(36)}`;
|
|
124
|
+
const token = (
|
|
125
|
+
await ctx.ui.input("Vercel API token (vercel-…):", "")
|
|
126
|
+
)?.trim();
|
|
127
|
+
if (!token) {
|
|
128
|
+
ctx.ui.notify("Deploy cancelled — no token", "warning");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const name =
|
|
132
|
+
(
|
|
133
|
+
await ctx.ui.input(
|
|
134
|
+
"Project name (empty = auto):",
|
|
135
|
+
defaultName,
|
|
136
|
+
)
|
|
137
|
+
)?.trim() || defaultName;
|
|
138
|
+
|
|
139
|
+
ctx.ui.setStatus("freeflow", "deploying relay…");
|
|
140
|
+
try {
|
|
141
|
+
const url = await deployVercelRelay(token, name, (m) =>
|
|
142
|
+
ctx.ui.notify(m, "info"),
|
|
143
|
+
);
|
|
144
|
+
setRelay(true, url, `deployed ${name}`);
|
|
145
|
+
persist();
|
|
146
|
+
ctx.ui.notify(`✓ Deployed & active: ${url}`, "info");
|
|
147
|
+
} catch (e) {
|
|
148
|
+
updateStatusBar(ctx.ui);
|
|
149
|
+
ctx.ui.notify(
|
|
150
|
+
`Deploy failed: ${(e as Error).message}`,
|
|
151
|
+
"error",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const switchRelay = async () => {
|
|
157
|
+
if (!relayState.relays.length) {
|
|
158
|
+
ctx.ui.notify("No saved relays yet", "warning");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const fmt = (r: KnownRelay) =>
|
|
162
|
+
`${r.url === relayState.url ? "★ " : " "}${r.url}${r.label ? ` (${r.label})` : ""}`;
|
|
163
|
+
const opts = relayState.relays.map(fmt);
|
|
164
|
+
const choice = await ctx.ui.select("Switch relay", opts);
|
|
165
|
+
if (!choice) return;
|
|
166
|
+
const match = relayState.relays.find((r) => fmt(r) === choice);
|
|
167
|
+
if (!match) return;
|
|
168
|
+
setRelay(true, match.url);
|
|
169
|
+
persist();
|
|
170
|
+
flash();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const showList = () => {
|
|
174
|
+
if (!relayState.relays.length) {
|
|
175
|
+
ctx.ui.notify("No saved relays", "info");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const lines = relayState.relays.map(
|
|
179
|
+
(r) =>
|
|
180
|
+
`${r.url === relayState.url ? "★" : " "} ${r.url}${r.label ? ` [${r.label}]` : ""}`,
|
|
181
|
+
);
|
|
182
|
+
ctx.ui.notify(
|
|
183
|
+
`Saved relays (${relayState.relays.length}):\n${lines.join("\n")}`,
|
|
184
|
+
"info",
|
|
185
|
+
);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const removeRelayMenu = async () => {
|
|
189
|
+
const removable = relayState.relays.filter(
|
|
190
|
+
(r) => r.url !== relayState.url,
|
|
191
|
+
);
|
|
192
|
+
if (!removable.length) {
|
|
193
|
+
ctx.ui.notify(
|
|
194
|
+
"Nothing to remove — the active relay cannot be removed (switch first)",
|
|
195
|
+
"warning",
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const fmt = (r: KnownRelay) =>
|
|
200
|
+
`${r.url}${r.label ? ` (${r.label})` : ""}`;
|
|
201
|
+
const choice = await ctx.ui.select(
|
|
202
|
+
"Remove relay",
|
|
203
|
+
removable.map(fmt),
|
|
204
|
+
);
|
|
205
|
+
if (!choice) return;
|
|
206
|
+
const match = removable.find((r) => fmt(r) === choice);
|
|
207
|
+
if (!match) return;
|
|
208
|
+
removeRelay(relayState, match.url);
|
|
209
|
+
persist();
|
|
210
|
+
ctx.ui.notify(`Removed: ${match.url}`, "info");
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
if (sub === "on") {
|
|
214
|
+
setRelay(true, relayState.url || DEFAULT_RELAY_URL);
|
|
215
|
+
persist();
|
|
216
|
+
flash();
|
|
217
|
+
} else if (sub === "off") {
|
|
218
|
+
relayState.enabled = false;
|
|
219
|
+
setActiveRelayState(relayState);
|
|
220
|
+
persist();
|
|
221
|
+
flash();
|
|
222
|
+
} else if (sub === "status") {
|
|
223
|
+
flash();
|
|
224
|
+
} else if (sub === "list") {
|
|
225
|
+
showList();
|
|
226
|
+
} else if (sub === "use") {
|
|
227
|
+
const url = (
|
|
228
|
+
rest ||
|
|
229
|
+
(await ctx.ui.input("Relay URL to activate:", "")) ||
|
|
230
|
+
""
|
|
231
|
+
).trim();
|
|
232
|
+
if (!url) {
|
|
233
|
+
ctx.ui.notify("No URL given", "warning");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
setRelay(true, url, "manual");
|
|
237
|
+
persist();
|
|
238
|
+
flash();
|
|
239
|
+
} else if (sub === "debug") {
|
|
240
|
+
const arg = rest.trim().toLowerCase();
|
|
241
|
+
if (arg === "on" || arg === "enable" || arg === "true") {
|
|
242
|
+
saveDebugState({ debug: true });
|
|
243
|
+
ctx.ui.notify(
|
|
244
|
+
"🔍 Debug ON — verbose trace enabled (level=debug). Logs now include request IDs, thinking sniffing, and payload normalize details.",
|
|
245
|
+
"info",
|
|
246
|
+
);
|
|
247
|
+
} else if (
|
|
248
|
+
arg === "off" ||
|
|
249
|
+
arg === "disable" ||
|
|
250
|
+
arg === "false"
|
|
251
|
+
) {
|
|
252
|
+
saveDebugState({ debug: false });
|
|
253
|
+
ctx.ui.notify(
|
|
254
|
+
`🔇 Debug OFF — level restored to info. File: ${DEBUG_STATE_FILE}`,
|
|
255
|
+
"info",
|
|
256
|
+
);
|
|
257
|
+
} else if (arg.startsWith("level")) {
|
|
258
|
+
const lvl = arg.split(/\s+/)[1] as LogLevel | undefined;
|
|
259
|
+
if (lvl && lvl in LOG_LEVEL_ORDER) {
|
|
260
|
+
saveDebugState({ debug: false, level: lvl });
|
|
261
|
+
ctx.ui.notify(
|
|
262
|
+
`Log level set to ${lvl} (persisted to ${DEBUG_STATE_FILE})`,
|
|
263
|
+
"info",
|
|
264
|
+
);
|
|
265
|
+
} else {
|
|
266
|
+
ctx.ui.notify(
|
|
267
|
+
`Unknown level: ${lvl} (use debug/info/warn/error)`,
|
|
268
|
+
"warning",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
const st = loadDebugState();
|
|
273
|
+
const cur = st?.debug
|
|
274
|
+
? "debug (ON)"
|
|
275
|
+
: st?.level ||
|
|
276
|
+
process.env.FREEFLOW_LOG_LEVEL ||
|
|
277
|
+
"info";
|
|
278
|
+
ctx.ui.notify(
|
|
279
|
+
`Debug status: ${cur}\nFile: ${DEBUG_STATE_FILE}\nMinLevel: ${getMinLogLevel()} | isDebug=${isDebugEnabled()}\nUsage: /freeflow debug on|off | /freeflow debug level debug`,
|
|
280
|
+
"info",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
} else if (sub === "logs" || sub === "log" || sub === "trace") {
|
|
284
|
+
try {
|
|
285
|
+
const rawRest = rest.trim();
|
|
286
|
+
let filterLevel: LogLevel | null = null;
|
|
287
|
+
let filterReqId: string | null = null;
|
|
288
|
+
let count = 25;
|
|
289
|
+
|
|
290
|
+
if (sub === "trace" && rawRest) {
|
|
291
|
+
filterReqId = rawRest.split(/\s+/)[0];
|
|
292
|
+
} else if (rawRest) {
|
|
293
|
+
const tokens = rawRest.split(/\s+/);
|
|
294
|
+
for (const t of tokens) {
|
|
295
|
+
const lower = t.toLowerCase();
|
|
296
|
+
if (lower in LOG_LEVEL_ORDER) {
|
|
297
|
+
filterLevel = lower as LogLevel;
|
|
298
|
+
} else if (/^\d+$/.test(t)) {
|
|
299
|
+
count = Math.min(
|
|
300
|
+
200,
|
|
301
|
+
Math.max(5, Number.parseInt(t, 10)),
|
|
302
|
+
);
|
|
303
|
+
} else if (
|
|
304
|
+
/^[a-f0-9]{6,8}$/i.test(t) ||
|
|
305
|
+
t.startsWith("req=")
|
|
306
|
+
) {
|
|
307
|
+
filterReqId = t.replace(/^req=/, "");
|
|
308
|
+
} else if (lower === "trace" || lower === "req") {
|
|
309
|
+
continue;
|
|
310
|
+
} else {
|
|
311
|
+
filterReqId = t;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const result = readRecentLogs(
|
|
317
|
+
filterLevel,
|
|
318
|
+
filterReqId,
|
|
319
|
+
count,
|
|
320
|
+
);
|
|
321
|
+
if (result.lines.length === 0) {
|
|
322
|
+
if (result.totalLines === 0) {
|
|
323
|
+
ctx.ui.notify(
|
|
324
|
+
"Log file is empty (no logs yet)",
|
|
325
|
+
"info",
|
|
326
|
+
);
|
|
327
|
+
} else {
|
|
328
|
+
ctx.ui.notify(
|
|
329
|
+
`No logs matched (level=${filterLevel || "any"} reqId=${filterReqId || "any"} count=${count})`,
|
|
330
|
+
"warning",
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const header = `pi-freeflow logs (last ${result.lines.length}/${result.totalMatched} matched, total ${result.totalLines} lines, file: ${LOG_FILE}${filterLevel ? ` level=${filterLevel}` : ""}${filterReqId ? ` req=${filterReqId}` : ""}):`;
|
|
337
|
+
ctx.ui.notify(
|
|
338
|
+
`${header}\n\n${result.lines.join("\n")}`,
|
|
339
|
+
"info",
|
|
340
|
+
);
|
|
341
|
+
} catch (e) {
|
|
342
|
+
ctx.ui.notify(
|
|
343
|
+
`Could not read log file: ${(e as Error).message}`,
|
|
344
|
+
"error",
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
} else if (
|
|
348
|
+
sub === "refresh" ||
|
|
349
|
+
sub === "reload" ||
|
|
350
|
+
sub === "models"
|
|
351
|
+
) {
|
|
352
|
+
ctx.ui.notify(
|
|
353
|
+
"Refreshing model catalog from live upstreams…",
|
|
354
|
+
"info",
|
|
355
|
+
);
|
|
356
|
+
const updated = await refreshCatalog(true);
|
|
357
|
+
setAliveCatalog(updated);
|
|
358
|
+
persist();
|
|
359
|
+
onCatalogRefreshed?.(updated);
|
|
360
|
+
ctx.ui.notify(
|
|
361
|
+
`✓ Refreshed ${updated.length} models with full-spec metadata!`,
|
|
362
|
+
"info",
|
|
363
|
+
);
|
|
364
|
+
} else if (sub === "remove") {
|
|
365
|
+
const url = (
|
|
366
|
+
rest ||
|
|
367
|
+
(await ctx.ui.input("Relay URL to remove:", "")) ||
|
|
368
|
+
""
|
|
369
|
+
).trim();
|
|
370
|
+
if (!url) {
|
|
371
|
+
ctx.ui.notify("No URL given", "warning");
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (url === relayState.url) {
|
|
375
|
+
ctx.ui.notify(
|
|
376
|
+
"Cannot remove the active relay — switch first",
|
|
377
|
+
"warning",
|
|
378
|
+
);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (!relayState.relays.some((r) => r.url === url)) {
|
|
382
|
+
ctx.ui.notify("Not in saved list", "warning");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
removeRelay(relayState, url);
|
|
386
|
+
persist();
|
|
387
|
+
ctx.ui.notify(`Removed: ${url}`, "info");
|
|
388
|
+
} else if (sub === "url") {
|
|
389
|
+
const input =
|
|
390
|
+
rest ||
|
|
391
|
+
(await ctx.ui.input(
|
|
392
|
+
"Relay URL (empty = default):",
|
|
393
|
+
relayState.url || DEFAULT_RELAY_URL,
|
|
394
|
+
));
|
|
395
|
+
setRelay(
|
|
396
|
+
relayState.enabled,
|
|
397
|
+
(input || "").trim() || DEFAULT_RELAY_URL,
|
|
398
|
+
"manual",
|
|
399
|
+
);
|
|
400
|
+
persist();
|
|
401
|
+
flash();
|
|
402
|
+
} else if (sub === "deploy") {
|
|
403
|
+
await doDeploy();
|
|
404
|
+
} else {
|
|
405
|
+
const choice = await ctx.ui.select("freeflow relay", [
|
|
406
|
+
`Relay: ${relayState.enabled ? "ON" : "OFF"} → ${relayState.url || "direct"}`,
|
|
407
|
+
"Turn ON",
|
|
408
|
+
"Turn OFF",
|
|
409
|
+
"Switch relay…",
|
|
410
|
+
"Remove relay…",
|
|
411
|
+
"Set URL",
|
|
412
|
+
"Deploy Vercel relay…",
|
|
413
|
+
"List saved relays",
|
|
414
|
+
]);
|
|
415
|
+
if (choice === "Turn ON") {
|
|
416
|
+
setRelay(true, relayState.url || DEFAULT_RELAY_URL);
|
|
417
|
+
persist();
|
|
418
|
+
flash();
|
|
419
|
+
} else if (choice === "Turn OFF") {
|
|
420
|
+
relayState.enabled = false;
|
|
421
|
+
setActiveRelayState(relayState);
|
|
422
|
+
persist();
|
|
423
|
+
flash();
|
|
424
|
+
} else if (choice === "Switch relay…") {
|
|
425
|
+
await switchRelay();
|
|
426
|
+
} else if (choice === "Remove relay…") {
|
|
427
|
+
await removeRelayMenu();
|
|
428
|
+
} else if (choice === "Set URL") {
|
|
429
|
+
const input = await ctx.ui.input(
|
|
430
|
+
"Relay URL (empty = default):",
|
|
431
|
+
relayState.url || DEFAULT_RELAY_URL,
|
|
432
|
+
);
|
|
433
|
+
setRelay(
|
|
434
|
+
relayState.enabled,
|
|
435
|
+
(input || "").trim() || DEFAULT_RELAY_URL,
|
|
436
|
+
"manual",
|
|
437
|
+
);
|
|
438
|
+
persist();
|
|
439
|
+
flash();
|
|
440
|
+
} else if (choice === "Deploy Vercel relay…") {
|
|
441
|
+
await doDeploy();
|
|
442
|
+
} else if (choice === "List saved relays") {
|
|
443
|
+
showList();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|