pi-freeflow 1.1.6 → 1.1.8
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/extensions/index.ts +401 -90
- package/package.json +1 -1
package/extensions/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import https from "node:https";
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import { Readable } from "node:stream";
|
|
13
|
+
import type { ReadableStream as WebReadableStream } from "node:stream/web";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
14
15
|
import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
15
16
|
|
|
@@ -174,8 +175,11 @@ let aliveCatalog: RegisteredModel[] = [];
|
|
|
174
175
|
async function relayFetch(
|
|
175
176
|
url: string,
|
|
176
177
|
opts: RequestInit = {},
|
|
178
|
+
reqId?: string,
|
|
177
179
|
): Promise<Response> {
|
|
180
|
+
const rid = reqId || randomUUID().slice(0, 8);
|
|
178
181
|
if (!relayState.enabled) {
|
|
182
|
+
log("debug", `relayFetch: direct (relay disabled) -> ${url}`, undefined, rid);
|
|
179
183
|
return fetch(url, opts);
|
|
180
184
|
}
|
|
181
185
|
|
|
@@ -193,7 +197,22 @@ async function relayFetch(
|
|
|
193
197
|
? (opts.body.length / 1024).toFixed(1)
|
|
194
198
|
: "0";
|
|
195
199
|
|
|
196
|
-
log("info", `request starting (${bodySizeKB}KB payload) -> ${url}
|
|
200
|
+
log("info", `request starting (${bodySizeKB}KB payload) -> ${url}`, undefined, rid);
|
|
201
|
+
if (isDebugEnabled()) {
|
|
202
|
+
try {
|
|
203
|
+
const bodyPreview = typeof opts.body === "string" ? opts.body.slice(0, 1200) : "";
|
|
204
|
+
const modelMatch = bodyPreview.match(/"model"\s*:\s*"([^"]+)"/);
|
|
205
|
+
const streamMatch = bodyPreview.match(/"stream"\s*:\s*(true|false)/);
|
|
206
|
+
log("debug", `request detail`, {
|
|
207
|
+
model: modelMatch?.[1],
|
|
208
|
+
stream: streamMatch?.[1],
|
|
209
|
+
sizeKB: bodySizeKB,
|
|
210
|
+
relayTarget,
|
|
211
|
+
relayPath,
|
|
212
|
+
candidates: candidates.length,
|
|
213
|
+
}, rid);
|
|
214
|
+
} catch {}
|
|
215
|
+
}
|
|
197
216
|
|
|
198
217
|
for (let i = 0; i < candidates.length; i++) {
|
|
199
218
|
const targetUrl = candidates[i];
|
|
@@ -207,6 +226,7 @@ async function relayFetch(
|
|
|
207
226
|
headers.set("x-relay-target", relayTarget);
|
|
208
227
|
headers.set("x-relay-path", relayPath);
|
|
209
228
|
headers.set("host", targetHost);
|
|
229
|
+
headers.set("x-request-id", rid);
|
|
210
230
|
const signal = opts.signal || AbortSignal.timeout(300_000);
|
|
211
231
|
const res = await fetch(targetUrl, { ...opts, headers, signal });
|
|
212
232
|
const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
|
|
@@ -218,6 +238,7 @@ async function relayFetch(
|
|
|
218
238
|
"warn",
|
|
219
239
|
`relay ${targetUrl} hit HTTP 504 Gateway Timeout in ${elapsed}s (prompt evaluation exceeded Vercel 25s limit) — fast fallback to direct upstream`,
|
|
220
240
|
{ upstream: url, sizeKB: bodySizeKB },
|
|
241
|
+
rid,
|
|
221
242
|
);
|
|
222
243
|
break;
|
|
223
244
|
}
|
|
@@ -228,6 +249,7 @@ async function relayFetch(
|
|
|
228
249
|
"warn",
|
|
229
250
|
`relay ${targetUrl} returned HTTP ${res.status} in ${elapsed}s — rolling to next relay`,
|
|
230
251
|
{ upstream: url, status: res.status },
|
|
252
|
+
rid,
|
|
231
253
|
);
|
|
232
254
|
continue;
|
|
233
255
|
}
|
|
@@ -237,12 +259,19 @@ async function relayFetch(
|
|
|
237
259
|
if (relayState.url !== targetUrl) {
|
|
238
260
|
log("info", `active relay auto-switched to ${targetUrl}`, {
|
|
239
261
|
previous: relayState.url,
|
|
240
|
-
});
|
|
262
|
+
}, rid);
|
|
241
263
|
relayState.url = targetUrl;
|
|
242
264
|
saveRelayState(relayState);
|
|
243
265
|
}
|
|
244
266
|
|
|
245
|
-
log("info", `relay ${targetUrl} succeeded (HTTP ${res.status} in ${elapsed}s)
|
|
267
|
+
log("info", `relay ${targetUrl} succeeded (HTTP ${res.status} in ${elapsed}s)`, undefined, rid);
|
|
268
|
+
if (isDebugEnabled()) {
|
|
269
|
+
log("debug", `relay headers`, {
|
|
270
|
+
status: res.status,
|
|
271
|
+
contentType: res.headers.get("content-type"),
|
|
272
|
+
via: res.headers.get("via") || res.headers.get("x-vercel-id") || "direct",
|
|
273
|
+
}, rid);
|
|
274
|
+
}
|
|
246
275
|
|
|
247
276
|
// Update TUI status
|
|
248
277
|
const label = shortRelayLabel(targetUrl);
|
|
@@ -261,6 +290,7 @@ async function relayFetch(
|
|
|
261
290
|
"warn",
|
|
262
291
|
`relay ${targetUrl} fetch error in ${elapsed}s — rolling to next relay`,
|
|
263
292
|
{ upstream: url, error: String(err) },
|
|
293
|
+
rid,
|
|
264
294
|
);
|
|
265
295
|
continue;
|
|
266
296
|
}
|
|
@@ -272,21 +302,22 @@ async function relayFetch(
|
|
|
272
302
|
log("warn", "relays bypassed/exhausted — attempting direct fetch to upstream", {
|
|
273
303
|
upstream: url,
|
|
274
304
|
sizeKB: bodySizeKB,
|
|
275
|
-
});
|
|
305
|
+
}, rid);
|
|
276
306
|
const directHeaders = new Headers(opts.headers);
|
|
277
307
|
directHeaders.delete("x-relay-target");
|
|
278
308
|
directHeaders.delete("x-relay-path");
|
|
279
309
|
directHeaders.set("host", u.host);
|
|
310
|
+
directHeaders.set("x-request-id", rid);
|
|
280
311
|
const directRes = await fetch(url, { ...opts, headers: directHeaders });
|
|
281
312
|
const directElapsed = ((Date.now() - directStart) / 1000).toFixed(1);
|
|
282
|
-
log("info", `direct fetch returned HTTP ${directRes.status} in ${directElapsed}s
|
|
313
|
+
log("info", `direct fetch returned HTTP ${directRes.status} in ${directElapsed}s`, undefined, rid);
|
|
283
314
|
return directRes;
|
|
284
315
|
} catch (directErr) {
|
|
285
316
|
const directElapsed = ((Date.now() - directStart) / 1000).toFixed(1);
|
|
286
317
|
log("error", `direct fallback also failed in ${directElapsed}s`, {
|
|
287
318
|
upstream: url,
|
|
288
319
|
error: String(directErr),
|
|
289
|
-
});
|
|
320
|
+
}, rid);
|
|
290
321
|
if (lastResponse) return lastResponse;
|
|
291
322
|
throw directErr || lastError;
|
|
292
323
|
}
|
|
@@ -686,23 +717,119 @@ const STRIP_HEADERS = new Set([
|
|
|
686
717
|
"proxy-authorization",
|
|
687
718
|
]);
|
|
688
719
|
|
|
689
|
-
// ── Logger
|
|
690
|
-
|
|
691
|
-
|
|
720
|
+
// ── Logger (structured, leveled, rotating, request-aware) ─────────
|
|
721
|
+
// Reference: pi-ai SDK diagnostics (provider streaming + thinking deltas)
|
|
722
|
+
// - api/openai-completions.js: thinkingFormat branches + reasoning_effort mapping
|
|
723
|
+
// - api/openai-responses-shared.js: reasoning block handling for Responses API
|
|
724
|
+
// - api/anthropic-messages.js: thinking/thinking_delta + signature handling
|
|
725
|
+
// This logger mirrors that trace plane for offline audit without TUI noise.
|
|
726
|
+
type LogLevel = "debug" | "info" | "warn" | "error" | "audit";
|
|
727
|
+
const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
|
|
728
|
+
debug: 0,
|
|
729
|
+
info: 1,
|
|
730
|
+
warn: 2,
|
|
731
|
+
error: 3,
|
|
732
|
+
audit: 4,
|
|
733
|
+
};
|
|
734
|
+
const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
735
|
+
const LOG_MAX_FILES = 3;
|
|
736
|
+
const DEBUG_STATE_FILE = path.join(homedir(), ".pi", "agent", "pi-freeflow-debug.json");
|
|
737
|
+
interface DebugState { debug: boolean; level?: LogLevel }
|
|
738
|
+
function loadDebugState(): DebugState | null {
|
|
692
739
|
try {
|
|
693
|
-
|
|
694
|
-
const
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
740
|
+
if (!fs.existsSync(DEBUG_STATE_FILE)) return null;
|
|
741
|
+
const raw = fs.readFileSync(DEBUG_STATE_FILE, "utf8");
|
|
742
|
+
const d = JSON.parse(raw) as DebugState;
|
|
743
|
+
if (typeof d.debug === "boolean") return d;
|
|
744
|
+
} catch {}
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
function saveDebugState(s: DebugState): void {
|
|
748
|
+
try {
|
|
749
|
+
const dir = path.dirname(DEBUG_STATE_FILE);
|
|
750
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
751
|
+
const tmp = `${DEBUG_STATE_FILE}.${randomUUID()}.tmp`;
|
|
752
|
+
fs.writeFileSync(tmp, JSON.stringify(s, null, 2), "utf8");
|
|
753
|
+
fs.renameSync(tmp, DEBUG_STATE_FILE);
|
|
754
|
+
} catch {}
|
|
755
|
+
}
|
|
756
|
+
function getMinLogLevel(): number {
|
|
757
|
+
const dbg = loadDebugState();
|
|
758
|
+
if (dbg?.debug) return LOG_LEVEL_ORDER.debug;
|
|
759
|
+
if (dbg?.level && dbg.level in LOG_LEVEL_ORDER) return LOG_LEVEL_ORDER[dbg.level];
|
|
760
|
+
const raw = (
|
|
761
|
+
process.env.FREEFLOW_LOG_LEVEL ||
|
|
762
|
+
process.env.BANSOS_LOG_LEVEL ||
|
|
763
|
+
"info"
|
|
764
|
+
).toLowerCase();
|
|
765
|
+
if (raw in LOG_LEVEL_ORDER) return LOG_LEVEL_ORDER[raw as LogLevel];
|
|
766
|
+
if (process.env.FREEFLOW_DEBUG === "1" || process.env.FREEFLOW_DEBUG === "true") {
|
|
767
|
+
return LOG_LEVEL_ORDER.debug;
|
|
768
|
+
}
|
|
769
|
+
return LOG_LEVEL_ORDER.info;
|
|
770
|
+
}
|
|
771
|
+
function shouldLog(level: LogLevel): boolean {
|
|
772
|
+
return LOG_LEVEL_ORDER[level] >= getMinLogLevel();
|
|
773
|
+
}
|
|
774
|
+
function isDebugEnabled(): boolean {
|
|
775
|
+
return LOG_LEVEL_ORDER.debug >= getMinLogLevel();
|
|
776
|
+
}
|
|
777
|
+
function rotateLogsIfNeeded(): void {
|
|
778
|
+
try {
|
|
779
|
+
if (!fs.existsSync(LOG_FILE)) return;
|
|
780
|
+
if (fs.statSync(LOG_FILE).size <= LOG_MAX_BYTES) return;
|
|
781
|
+
for (let i = LOG_MAX_FILES - 1; i >= 1; i--) {
|
|
782
|
+
const src = i === 1 ? LOG_FILE : `${LOG_FILE}.${i - 1}`;
|
|
783
|
+
const dst = `${LOG_FILE}.${i}`;
|
|
784
|
+
try {
|
|
785
|
+
if (fs.existsSync(src)) {
|
|
786
|
+
if (fs.existsSync(dst)) fs.unlinkSync(dst);
|
|
787
|
+
fs.renameSync(src, dst);
|
|
788
|
+
}
|
|
789
|
+
} catch {}
|
|
790
|
+
}
|
|
791
|
+
} catch {}
|
|
792
|
+
}
|
|
793
|
+
function formatLogMeta(
|
|
794
|
+
meta?: Record<string, unknown>,
|
|
795
|
+
reqId?: string,
|
|
796
|
+
): string {
|
|
797
|
+
const parts: string[] = [];
|
|
798
|
+
if (reqId) parts.push(`req=${reqId}`);
|
|
799
|
+
if (meta && Object.keys(meta).length > 0) {
|
|
800
|
+
const safe: Record<string, unknown> = {};
|
|
801
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
802
|
+
if (typeof v === "string" && v.length > 800) {
|
|
803
|
+
safe[k] = `${v.slice(0, 800)}…(${v.length})`;
|
|
804
|
+
} else {
|
|
805
|
+
safe[k] = v;
|
|
701
806
|
}
|
|
702
|
-
}
|
|
807
|
+
}
|
|
808
|
+
parts.push(JSON.stringify(safe));
|
|
809
|
+
}
|
|
810
|
+
return parts.length ? ` ${parts.join(" ")}` : "";
|
|
811
|
+
}
|
|
812
|
+
function log(
|
|
813
|
+
level: LogLevel,
|
|
814
|
+
message: string,
|
|
815
|
+
meta?: Record<string, unknown>,
|
|
816
|
+
reqId?: string,
|
|
817
|
+
): void {
|
|
818
|
+
if (!shouldLog(level)) return;
|
|
819
|
+
try {
|
|
820
|
+
const ts = new Date().toISOString();
|
|
821
|
+
const line = `[${ts}] [${level.toUpperCase()}]${reqId ? ` [${reqId}]` : ""} ${message}${formatLogMeta(meta, undefined)}\n`;
|
|
822
|
+
rotateLogsIfNeeded();
|
|
703
823
|
fs.appendFileSync(LOG_FILE, line, "utf8");
|
|
704
824
|
} catch {}
|
|
705
825
|
}
|
|
826
|
+
function logDebug(
|
|
827
|
+
message: string,
|
|
828
|
+
meta?: Record<string, unknown>,
|
|
829
|
+
reqId?: string,
|
|
830
|
+
): void {
|
|
831
|
+
log("debug", message, meta, reqId);
|
|
832
|
+
}
|
|
706
833
|
|
|
707
834
|
// ── Rate Limiter ───────────────────────────────────────────────────
|
|
708
835
|
// Kilo documents 200 free requests/hour/IP. OpenCode owns its own daily quota;
|
|
@@ -984,8 +1111,24 @@ function normalizeRequestBody(
|
|
|
984
1111
|
body: Record<string, unknown>,
|
|
985
1112
|
isRelay = false,
|
|
986
1113
|
isKilo = false,
|
|
1114
|
+
reqId?: string,
|
|
987
1115
|
): Record<string, unknown> {
|
|
988
|
-
|
|
1116
|
+
const DBG = isDebugEnabled();
|
|
1117
|
+
const modelId = typeof body.model === "string" ? body.model : "";
|
|
1118
|
+
const modelDef = MODEL_MAP.get(modelId);
|
|
1119
|
+
const isResponsesApi = modelId === "muse-spark-1.2-contributor-free";
|
|
1120
|
+
|
|
1121
|
+
if (DBG) {
|
|
1122
|
+
log("debug", `normalize: incoming model=${modelId} kilo=${isKilo} relay=${isRelay}`, {
|
|
1123
|
+
reasoning_effort: body.reasoning_effort,
|
|
1124
|
+
reasoning: body.reasoning,
|
|
1125
|
+
thinking: (body as Record<string, unknown>).thinking,
|
|
1126
|
+
tool_choice: body.tool_choice,
|
|
1127
|
+
toolsLen: Array.isArray(body.tools) ? body.tools.length : undefined,
|
|
1128
|
+
}, reqId);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// 1. Tool choice & empty tools normalization (pi-ai compat: opencode only supports auto)
|
|
989
1132
|
if (Array.isArray(body.tools) && body.tools.length === 0) {
|
|
990
1133
|
delete body.tools;
|
|
991
1134
|
delete body.tool_choice;
|
|
@@ -994,26 +1137,57 @@ function normalizeRequestBody(
|
|
|
994
1137
|
delete body.tool_choice;
|
|
995
1138
|
delete body.tools;
|
|
996
1139
|
} else if (!isKilo && body.tool_choice && body.tool_choice !== "auto") {
|
|
997
|
-
// OpenCode Zen only supports "auto" or undefined
|
|
998
1140
|
body.tool_choice = "auto";
|
|
999
1141
|
}
|
|
1000
1142
|
|
|
1001
|
-
//
|
|
1002
|
-
|
|
1003
|
-
|
|
1143
|
+
// 1b. Anthropic thinking -> OpenAI reasoning_effort auto-translate
|
|
1144
|
+
// Pi sends anthropic `thinking: {type:"enabled",budget_tokens}` when provider is anthropic.
|
|
1145
|
+
// Our proxy is always openai-completions/responses upstream, so translate.
|
|
1146
|
+
// Ref: pi-ai api/anthropic-messages.js (thinking.type adaptive/enabled/disabled) -> api/openai-completions.js (reasoning_effort)
|
|
1147
|
+
const thinkingRaw = (body as Record<string, unknown>).thinking;
|
|
1148
|
+
if (thinkingRaw && typeof thinkingRaw === "object") {
|
|
1149
|
+
const th = thinkingRaw as Record<string, unknown>;
|
|
1150
|
+
if (th.type === "disabled") {
|
|
1151
|
+
delete (body as Record<string, unknown>).thinking;
|
|
1152
|
+
// Mark as off so downstream reasoning mapping can clear effort
|
|
1153
|
+
if (!body.reasoning_effort && !body.reasoning) {
|
|
1154
|
+
body.reasoning_effort = "off";
|
|
1155
|
+
}
|
|
1156
|
+
} else if (th.type === "enabled" || th.type === "adaptive") {
|
|
1157
|
+
delete (body as Record<string, unknown>).thinking;
|
|
1158
|
+
// Preserve budget as hint if no explicit effort set
|
|
1159
|
+
if (!body.reasoning_effort && typeof th.budget_tokens === "number") {
|
|
1160
|
+
const budget = th.budget_tokens as number;
|
|
1161
|
+
if (budget >= 8000) body.reasoning_effort = "xhigh";
|
|
1162
|
+
else if (budget >= 4000) body.reasoning_effort = "high";
|
|
1163
|
+
else if (budget >= 2000) body.reasoning_effort = "medium";
|
|
1164
|
+
else body.reasoning_effort = "low";
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// 2. Reasoning normalization — per-model thinkingLevelMap aware
|
|
1170
|
+
// Ref: pi-ai api/openai-completions.js (compat.thinkingFormat branches) + api/openai-responses-shared.js
|
|
1171
|
+
const mapEffort = (rawEffort: string): string | null | undefined => {
|
|
1172
|
+
const key = rawEffort.toLowerCase() as keyof NonNullable<ModelDef["thinkingLevelMap"]>;
|
|
1173
|
+
if (modelDef?.thinkingLevelMap && key in modelDef.thinkingLevelMap) {
|
|
1174
|
+
return modelDef.thinkingLevelMap[key] as string | null;
|
|
1175
|
+
}
|
|
1176
|
+
if (rawEffort === "xhigh" || rawEffort === "max") {
|
|
1177
|
+
return isResponsesApi ? "xhigh" : (modelId === "x-preview-f-free" ? "max" : "xhigh");
|
|
1178
|
+
}
|
|
1179
|
+
if (rawEffort === "high" || rawEffort === "medium") return "high";
|
|
1180
|
+
if (rawEffort === "minimal") return "minimal";
|
|
1181
|
+
if (rawEffort === "none" || rawEffort === "off") return null;
|
|
1182
|
+
return "low";
|
|
1183
|
+
};
|
|
1004
1184
|
|
|
1005
1185
|
if (typeof body.reasoning_effort === "string") {
|
|
1006
|
-
const
|
|
1007
|
-
if (
|
|
1008
|
-
body.reasoning_effort = isResponsesApi ? "xhigh" : (modelId === "x-preview-f-free" ? "max" : "xhigh");
|
|
1009
|
-
} else if (re === "high" || re === "medium") {
|
|
1010
|
-
body.reasoning_effort = "high";
|
|
1011
|
-
} else if (re === "minimal") {
|
|
1012
|
-
body.reasoning_effort = "minimal";
|
|
1013
|
-
} else if (re === "none" || re === "off") {
|
|
1186
|
+
const mapped = mapEffort(body.reasoning_effort);
|
|
1187
|
+
if (mapped === null || mapped === undefined) {
|
|
1014
1188
|
delete body.reasoning_effort;
|
|
1015
1189
|
} else {
|
|
1016
|
-
body.reasoning_effort =
|
|
1190
|
+
body.reasoning_effort = mapped;
|
|
1017
1191
|
}
|
|
1018
1192
|
}
|
|
1019
1193
|
if (body.reasoning && typeof body.reasoning === "object") {
|
|
@@ -1021,21 +1195,17 @@ function normalizeRequestBody(
|
|
|
1021
1195
|
if (r.effort === "none" || r.effort === "off") {
|
|
1022
1196
|
delete r.effort;
|
|
1023
1197
|
} else if (typeof r.effort === "string") {
|
|
1024
|
-
const
|
|
1025
|
-
if (
|
|
1026
|
-
r.effort
|
|
1027
|
-
} else if (re === "high" || re === "medium") {
|
|
1028
|
-
r.effort = "high";
|
|
1029
|
-
} else if (re === "minimal") {
|
|
1030
|
-
r.effort = "minimal";
|
|
1198
|
+
const mapped = mapEffort(r.effort);
|
|
1199
|
+
if (mapped === null || mapped === undefined) {
|
|
1200
|
+
delete r.effort;
|
|
1031
1201
|
} else {
|
|
1032
|
-
r.effort =
|
|
1202
|
+
r.effort = mapped;
|
|
1033
1203
|
}
|
|
1034
1204
|
}
|
|
1205
|
+
if (isResponsesApi && r.effort === "max") r.effort = "xhigh";
|
|
1035
1206
|
}
|
|
1036
1207
|
|
|
1037
1208
|
// 3. Max & Min tokens clamping (model-specific clamp + Vercel relay clamp)
|
|
1038
|
-
const modelDef = MODEL_MAP.get(modelId);
|
|
1039
1209
|
const modelMax = modelDef?.maxTokens ?? RELAY_MAX_TOKENS;
|
|
1040
1210
|
const clampTokens = (val: number): number => {
|
|
1041
1211
|
let clamped = val;
|
|
@@ -1055,16 +1225,47 @@ function normalizeRequestBody(
|
|
|
1055
1225
|
body.max_output_tokens = clampTokens(body.max_output_tokens);
|
|
1056
1226
|
}
|
|
1057
1227
|
|
|
1228
|
+
if (DBG) {
|
|
1229
|
+
log("debug", `normalize: outgoing model=${modelId}`, {
|
|
1230
|
+
reasoning_effort: body.reasoning_effort,
|
|
1231
|
+
reasoning: body.reasoning,
|
|
1232
|
+
max_tokens: body.max_tokens,
|
|
1233
|
+
max_output_tokens: body.max_output_tokens,
|
|
1234
|
+
}, reqId);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1058
1237
|
return body;
|
|
1059
1238
|
}
|
|
1060
1239
|
|
|
1061
1240
|
// ponytail: shared stream pipe — upstream abort/timeout must end response,
|
|
1062
1241
|
// not become an uncaught exception that crashes pi.
|
|
1242
|
+
// Adds lightweight thinking-tag sniffing for debug audit (no payload mutation).
|
|
1243
|
+
// Ref: pi-ai api/openai-completions.js (thinkingDelta) + api/openai-responses-shared.js (reasoning)
|
|
1063
1244
|
function pipeUpstreamStream(
|
|
1064
1245
|
nodeStream: Readable,
|
|
1065
1246
|
res: http.ServerResponse,
|
|
1066
1247
|
req: http.IncomingMessage,
|
|
1248
|
+
reqId?: string,
|
|
1067
1249
|
): void {
|
|
1250
|
+
const rid = reqId || randomUUID().slice(0, 8);
|
|
1251
|
+
let totalChunks = 0;
|
|
1252
|
+
let totalBytes = 0;
|
|
1253
|
+
let thinkingChunks = 0;
|
|
1254
|
+
let thinkingBytes = 0;
|
|
1255
|
+
let firstChunkAt: number | null = null;
|
|
1256
|
+
const startAt = Date.now();
|
|
1257
|
+
const sniffThinking = (chunk: Buffer | string): boolean => {
|
|
1258
|
+
const s = typeof chunk === "string" ? chunk : chunk.toString("utf8", 0, Math.min(chunk.length, 4000));
|
|
1259
|
+
return (
|
|
1260
|
+
s.includes("reasoning") ||
|
|
1261
|
+
s.includes("thinking") ||
|
|
1262
|
+
s.includes("<think>") ||
|
|
1263
|
+
s.includes("reasoning_content") ||
|
|
1264
|
+
s.includes("\"type\":\"thinking\"") ||
|
|
1265
|
+
s.includes("thinking_delta")
|
|
1266
|
+
);
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1068
1269
|
try {
|
|
1069
1270
|
if (typeof res.flushHeaders === "function") {
|
|
1070
1271
|
res.flushHeaders();
|
|
@@ -1073,15 +1274,29 @@ function pipeUpstreamStream(
|
|
|
1073
1274
|
|
|
1074
1275
|
nodeStream.on("data", (chunk: Buffer | string) => {
|
|
1075
1276
|
try {
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1277
|
+
if (firstChunkAt === null) {
|
|
1278
|
+
firstChunkAt = Date.now();
|
|
1279
|
+
const ttfb = firstChunkAt - startAt;
|
|
1280
|
+
log("debug", `stream first chunk in ${ttfb}ms`, undefined, rid);
|
|
1281
|
+
}
|
|
1282
|
+
totalChunks++;
|
|
1283
|
+
totalBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
|
|
1284
|
+
if (sniffThinking(chunk)) {
|
|
1285
|
+
thinkingChunks++;
|
|
1286
|
+
thinkingBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
|
|
1287
|
+
if (isDebugEnabled() && thinkingChunks <= 3) {
|
|
1288
|
+
const preview = typeof chunk === "string" ? chunk.slice(0, 600) : chunk.toString("utf8", 0, 600);
|
|
1289
|
+
log("debug", `thinking chunk #${thinkingChunks}`, { preview: preview.slice(0, 400) }, rid);
|
|
1290
|
+
}
|
|
1079
1291
|
}
|
|
1292
|
+
res.write(chunk);
|
|
1293
|
+
const maybeFlush = res as unknown as { flush?: () => void };
|
|
1294
|
+
if (typeof maybeFlush.flush === "function") maybeFlush.flush();
|
|
1080
1295
|
} catch {}
|
|
1081
1296
|
});
|
|
1082
1297
|
|
|
1083
1298
|
nodeStream.on("error", (e: unknown) => {
|
|
1084
|
-
log("error", "upstream stream error", { error: String(e) });
|
|
1299
|
+
log("error", "upstream stream error", { error: String(e), totalChunks, thinkingChunks }, rid);
|
|
1085
1300
|
try {
|
|
1086
1301
|
if (!res.headersSent) {
|
|
1087
1302
|
res.writeHead(502, { "content-type": "application/json" });
|
|
@@ -1092,6 +1307,12 @@ function pipeUpstreamStream(
|
|
|
1092
1307
|
} catch {}
|
|
1093
1308
|
});
|
|
1094
1309
|
nodeStream.on("end", () => {
|
|
1310
|
+
const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
|
|
1311
|
+
if (thinkingChunks > 0) {
|
|
1312
|
+
log("info", `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes/1024).toFixed(1)}KB), thinking: ${thinkingChunks} chunks (${(thinkingBytes/1024).toFixed(1)}KB)`, undefined, rid);
|
|
1313
|
+
} else if (isDebugEnabled()) {
|
|
1314
|
+
log("debug", `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes/1024).toFixed(1)}KB), no thinking detected`, undefined, rid);
|
|
1315
|
+
}
|
|
1095
1316
|
try {
|
|
1096
1317
|
if (!res.writableEnded) res.end();
|
|
1097
1318
|
} catch {}
|
|
@@ -1102,6 +1323,7 @@ function pipeUpstreamStream(
|
|
|
1102
1323
|
} catch {}
|
|
1103
1324
|
});
|
|
1104
1325
|
req.on("aborted", () => {
|
|
1326
|
+
log("warn", "client aborted — destroying upstream", { totalChunks }, rid);
|
|
1105
1327
|
if (!nodeStream.destroyed) nodeStream.destroy();
|
|
1106
1328
|
});
|
|
1107
1329
|
req.on("close", () => {
|
|
@@ -1123,7 +1345,10 @@ function startProxy(
|
|
|
1123
1345
|
|
|
1124
1346
|
const server = http.createServer((req, res) => {
|
|
1125
1347
|
const clientIP = getClientIP(req);
|
|
1126
|
-
|
|
1348
|
+
const reqId = randomUUID().slice(0, 8);
|
|
1349
|
+
if (isDebugEnabled()) {
|
|
1350
|
+
log("debug", `incoming ${req.method} ${req.url} from ${clientIP}`, { ip: clientIP, method: req.method, url: req.url }, reqId);
|
|
1351
|
+
}
|
|
1127
1352
|
if (!ALLOWED_METHODS.has(req.method ?? "")) {
|
|
1128
1353
|
res.writeHead(405, { "content-type": "application/json" });
|
|
1129
1354
|
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
@@ -1173,7 +1398,7 @@ function startProxy(
|
|
|
1173
1398
|
// Read body to detect model for routing
|
|
1174
1399
|
const bodyChunks: Buffer[] = [];
|
|
1175
1400
|
req.on("error", (err) => {
|
|
1176
|
-
log("warn", "client request error during body buffering", { error: String(err) });
|
|
1401
|
+
log("warn", "client request error during body buffering", { error: String(err) }, reqId);
|
|
1177
1402
|
if (!res.headersSent) res.writeHead(400, { "content-type": "application/json" });
|
|
1178
1403
|
res.end(JSON.stringify({ error: "bad request" }));
|
|
1179
1404
|
});
|
|
@@ -1194,13 +1419,17 @@ function startProxy(
|
|
|
1194
1419
|
} catch {}
|
|
1195
1420
|
|
|
1196
1421
|
const upstream: Upstream = isKilo ? "kilo" : "opencode";
|
|
1422
|
+
if (!checkRateLimit(clientIP, upstream)) {
|
|
1423
|
+
log("warn", `rate limit hit for ${upstream} from ${clientIP}`, { ip: clientIP, upstream }, reqId);
|
|
1424
|
+
res.writeHead(429, { "content-type": "application/json" });
|
|
1425
|
+
res.end(JSON.stringify({ error: "rate limit exceeded" }));
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1197
1428
|
|
|
1198
1429
|
try {
|
|
1199
1430
|
if (isKilo && parsedBody) {
|
|
1200
|
-
// KiloCode gateway routing (free models are keyless)
|
|
1201
|
-
const isStream = parsedBody.stream === true;
|
|
1202
1431
|
const kiloBodyObj = structuredClone(parsedBody);
|
|
1203
|
-
normalizeRequestBody(kiloBodyObj, true, isKilo);
|
|
1432
|
+
normalizeRequestBody(kiloBodyObj, true, isKilo, reqId);
|
|
1204
1433
|
const response = await relayFetch(KILO_CHAT_URL, {
|
|
1205
1434
|
method: "POST",
|
|
1206
1435
|
headers: {
|
|
@@ -1209,7 +1438,7 @@ function startProxy(
|
|
|
1209
1438
|
},
|
|
1210
1439
|
body: JSON.stringify(kiloBodyObj),
|
|
1211
1440
|
signal: AbortSignal.timeout(300_000),
|
|
1212
|
-
});
|
|
1441
|
+
}, reqId);
|
|
1213
1442
|
if (isStream && response.ok && response.body) {
|
|
1214
1443
|
const ct =
|
|
1215
1444
|
response.headers.get("content-type") || "text/event-stream";
|
|
@@ -1221,10 +1450,11 @@ function startProxy(
|
|
|
1221
1450
|
});
|
|
1222
1451
|
pipeUpstreamStream(
|
|
1223
1452
|
Readable.fromWeb(
|
|
1224
|
-
response.body as unknown as
|
|
1453
|
+
response.body as unknown as WebReadableStream,
|
|
1225
1454
|
),
|
|
1226
1455
|
res,
|
|
1227
1456
|
req,
|
|
1457
|
+
reqId,
|
|
1228
1458
|
);
|
|
1229
1459
|
} else {
|
|
1230
1460
|
const data = await response.text();
|
|
@@ -1244,12 +1474,9 @@ function startProxy(
|
|
|
1244
1474
|
activeHost,
|
|
1245
1475
|
);
|
|
1246
1476
|
try {
|
|
1247
|
-
let relayBody = bodyChunks.length
|
|
1248
|
-
? Buffer.concat(bodyChunks)
|
|
1249
|
-
: undefined;
|
|
1250
1477
|
if (relayBody && parsedBody) {
|
|
1251
1478
|
const relayBodyObj = structuredClone(parsedBody);
|
|
1252
|
-
normalizeRequestBody(relayBodyObj, true, isKilo);
|
|
1479
|
+
normalizeRequestBody(relayBodyObj, true, isKilo, reqId);
|
|
1253
1480
|
relayBody = Buffer.from(JSON.stringify(relayBodyObj));
|
|
1254
1481
|
}
|
|
1255
1482
|
const response = await relayFetch(fullUrl, {
|
|
@@ -1257,7 +1484,7 @@ function startProxy(
|
|
|
1257
1484
|
headers: relayHeaders,
|
|
1258
1485
|
body: relayBody,
|
|
1259
1486
|
signal: AbortSignal.timeout(300_000),
|
|
1260
|
-
});
|
|
1487
|
+
}, reqId);
|
|
1261
1488
|
const ct =
|
|
1262
1489
|
response.headers.get("content-type") || "application/json";
|
|
1263
1490
|
if (response.ok && response.body) {
|
|
@@ -1271,10 +1498,11 @@ function startProxy(
|
|
|
1271
1498
|
});
|
|
1272
1499
|
pipeUpstreamStream(
|
|
1273
1500
|
Readable.fromWeb(
|
|
1274
|
-
response.body as unknown as
|
|
1501
|
+
response.body as unknown as WebReadableStream,
|
|
1275
1502
|
),
|
|
1276
1503
|
res,
|
|
1277
1504
|
req,
|
|
1505
|
+
reqId,
|
|
1278
1506
|
);
|
|
1279
1507
|
} else {
|
|
1280
1508
|
const data = await response.text();
|
|
@@ -1286,19 +1514,23 @@ function startProxy(
|
|
|
1286
1514
|
return; // relay handled the response
|
|
1287
1515
|
} catch (e) {
|
|
1288
1516
|
log("warn", "opencode relay failed, falling back to direct", {
|
|
1289
|
-
|
|
1290
|
-
|
|
1517
|
+
error: String(e),
|
|
1518
|
+
}, reqId);
|
|
1291
1519
|
if (res.headersSent) return; // can't recover mid-stream
|
|
1292
1520
|
}
|
|
1293
1521
|
}
|
|
1294
1522
|
// direct path (existing, untouched)
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1523
|
+
// direct path — with debug trace and thinking-aware normalize
|
|
1524
|
+
let directBody = Buffer.concat(bodyChunks);
|
|
1525
|
+
if (parsedBody) {
|
|
1526
|
+
const directBodyObj = structuredClone(parsedBody);
|
|
1527
|
+
normalizeRequestBody(directBodyObj, false, isKilo, reqId);
|
|
1528
|
+
directBody = Buffer.from(JSON.stringify(directBodyObj));
|
|
1529
|
+
}
|
|
1530
|
+
if (isDebugEnabled()) {
|
|
1531
|
+
log("debug", `direct upstream ${target.hostname}${target.pathname} (${directBody.length}B)`, { model: parsedBody?.model, isKilo }, reqId);
|
|
1532
|
+
}
|
|
1533
|
+
const fwd = sanitizeHeaders(req.headers, target.hostname);
|
|
1302
1534
|
if (directBody.length > 0) {
|
|
1303
1535
|
fwd["content-length"] = String(directBody.byteLength);
|
|
1304
1536
|
}
|
|
@@ -1323,14 +1555,14 @@ function startProxy(
|
|
|
1323
1555
|
outHeaders["x-content-type-options"] = "nosniff";
|
|
1324
1556
|
res.writeHead(upstream.statusCode ?? 502, outHeaders);
|
|
1325
1557
|
upstream.on("error", (streamErr) => {
|
|
1326
|
-
log("error", "upstream stream error in direct proxy", { error: String(streamErr) });
|
|
1558
|
+
log("error", "upstream stream error in direct proxy", { error: String(streamErr) }, reqId);
|
|
1327
1559
|
if (!res.writableEnded) res.end();
|
|
1328
1560
|
});
|
|
1329
1561
|
upstream.pipe(res);
|
|
1330
1562
|
},
|
|
1331
1563
|
);
|
|
1332
1564
|
proxy.on("error", (proxyErr) => {
|
|
1333
|
-
log("error", "proxy socket error", { error: String(proxyErr) });
|
|
1565
|
+
log("error", "proxy socket error", { error: String(proxyErr) }, reqId);
|
|
1334
1566
|
if (!res.headersSent) {
|
|
1335
1567
|
res.writeHead(502, { "content-type": "application/json" });
|
|
1336
1568
|
res.end(JSON.stringify({ error: "upstream error" }));
|
|
@@ -1355,7 +1587,7 @@ function startProxy(
|
|
|
1355
1587
|
proxy.end(directBody);
|
|
1356
1588
|
}
|
|
1357
1589
|
} catch (err) {
|
|
1358
|
-
log("error", "proxy error", { error: String(err) });
|
|
1590
|
+
log("error", "proxy error", { error: String(err) }, reqId);
|
|
1359
1591
|
if (!res.headersSent)
|
|
1360
1592
|
res.writeHead(502, { "content-type": "application/json" });
|
|
1361
1593
|
res.end(JSON.stringify({ error: "internal error" }));
|
|
@@ -1480,12 +1712,12 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1480
1712
|
|
|
1481
1713
|
pi.registerProvider("freeflow", providerConfig);
|
|
1482
1714
|
}
|
|
1483
|
-
// ── /bansos command:
|
|
1715
|
+
// ── /bansos command: relay + debug + logs ───
|
|
1484
1716
|
const commandSpec = {
|
|
1485
1717
|
description:
|
|
1486
|
-
"Relay egress: on | off | status | logs | url [URL] | deploy | list | use <URL> | remove <URL> | refresh",
|
|
1718
|
+
"Relay egress: on | off | status | logs [level] [n] | debug on|off|status | url [URL] | deploy | list | use <URL> | remove <URL> | refresh",
|
|
1487
1719
|
getArgumentCompletions: (prefix: string) =>
|
|
1488
|
-
["on", "off", "status", "url", "deploy", "list", "use", "remove", "refresh", "models"]
|
|
1720
|
+
["on", "off", "status", "url", "deploy", "list", "use", "remove", "refresh", "models", "logs", "debug", "trace"]
|
|
1489
1721
|
.filter((s) => s.startsWith(prefix))
|
|
1490
1722
|
.map((s) => ({ value: s, label: s })),
|
|
1491
1723
|
handler: async (args: string, ctx) => {
|
|
@@ -1623,15 +1855,76 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1623
1855
|
setRelay(true, url, "manual");
|
|
1624
1856
|
persist();
|
|
1625
1857
|
flash();
|
|
1626
|
-
} else if (sub === "
|
|
1858
|
+
} else if (sub === "debug") {
|
|
1859
|
+
const arg = rest.trim().toLowerCase();
|
|
1860
|
+
if (arg === "on" || arg === "enable" || arg === "true") {
|
|
1861
|
+
saveDebugState({ debug: true });
|
|
1862
|
+
ctx.ui.notify("🔍 Debug ON — verbose trace enabled (level=debug). Logs now include request IDs, thinking sniffing, and payload normalize details.", "info");
|
|
1863
|
+
} else if (arg === "off" || arg === "disable" || arg === "false") {
|
|
1864
|
+
saveDebugState({ debug: false });
|
|
1865
|
+
ctx.ui.notify("🔇 Debug OFF — level restored to info. File: " + DEBUG_STATE_FILE, "info");
|
|
1866
|
+
} else if (arg.startsWith("level")) {
|
|
1867
|
+
const lvl = arg.split(/\s+/)[1] as LogLevel | undefined;
|
|
1868
|
+
if (lvl && lvl in LOG_LEVEL_ORDER) {
|
|
1869
|
+
saveDebugState({ debug: false, level: lvl });
|
|
1870
|
+
ctx.ui.notify(`Log level set to ${lvl} (persisted to ${DEBUG_STATE_FILE})`, "info");
|
|
1871
|
+
} else {
|
|
1872
|
+
ctx.ui.notify(`Unknown level: ${lvl} (use debug/info/warn/error)`, "warn");
|
|
1873
|
+
}
|
|
1874
|
+
} else {
|
|
1875
|
+
const st = loadDebugState();
|
|
1876
|
+
const cur = st?.debug ? "debug (ON)" : (st?.level || process.env.FREEFLOW_LOG_LEVEL || "info");
|
|
1877
|
+
ctx.ui.notify(`Debug status: ${cur}\nFile: ${DEBUG_STATE_FILE}\nMinLevel: ${getMinLogLevel()} | isDebug=${isDebugEnabled()}\nUsage: /freeflow debug on|off | /freeflow debug level debug`, "info");
|
|
1878
|
+
}
|
|
1879
|
+
} else if (sub === "logs" || sub === "log" || sub === "trace") {
|
|
1627
1880
|
try {
|
|
1628
|
-
|
|
1629
|
-
|
|
1881
|
+
const rawRest = rest.trim();
|
|
1882
|
+
let filterLevel: LogLevel | null = null;
|
|
1883
|
+
let filterReqId: string | null = null;
|
|
1884
|
+
let count = 25;
|
|
1885
|
+
// trace mode: sub === trace or rest starts with trace/req
|
|
1886
|
+
if (sub === "trace" && rawRest) {
|
|
1887
|
+
filterReqId = rawRest.split(/\s+/)[0];
|
|
1888
|
+
} else if (rawRest) {
|
|
1889
|
+
const tokens = rawRest.split(/\s+/);
|
|
1890
|
+
for (const t of tokens) {
|
|
1891
|
+
const lower = t.toLowerCase();
|
|
1892
|
+
if (lower in LOG_LEVEL_ORDER) filterLevel = lower as LogLevel;
|
|
1893
|
+
else if (/^\d+$/.test(t)) count = Math.min(200, Math.max(5, parseInt(t, 10)));
|
|
1894
|
+
else if (/^[a-f0-9]{6,8}$/i.test(t) || t.startsWith("req=")) filterReqId = t.replace(/^req=/, "");
|
|
1895
|
+
else if (lower === "trace" || lower === "req") continue;
|
|
1896
|
+
else filterReqId = t;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
const files: string[] = [LOG_FILE, `${LOG_FILE}.1`, `${LOG_FILE}.2`].filter((f) => fs.existsSync(f));
|
|
1900
|
+
if (files.length === 0) {
|
|
1901
|
+
ctx.ui.notify("Log file is empty (no logs yet)", "info");
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
let allLines: string[] = [];
|
|
1905
|
+
for (const f of files) {
|
|
1906
|
+
try {
|
|
1907
|
+
const c = fs.readFileSync(f, "utf8");
|
|
1908
|
+
const ls = c.trim().split("\n").filter(Boolean);
|
|
1909
|
+
allLines = ls.concat(allLines);
|
|
1910
|
+
} catch {}
|
|
1911
|
+
}
|
|
1912
|
+
let filtered = allLines;
|
|
1913
|
+
if (filterLevel) {
|
|
1914
|
+
const want = `[${filterLevel.toUpperCase()}]`;
|
|
1915
|
+
filtered = filtered.filter((l) => l.includes(want));
|
|
1916
|
+
}
|
|
1917
|
+
if (filterReqId) {
|
|
1918
|
+
// match [reqId] bracket or req= prefix
|
|
1919
|
+
filtered = filtered.filter((l) => l.includes(filterReqId as string) || l.includes(`[${filterReqId}]`));
|
|
1920
|
+
}
|
|
1921
|
+
const lines = filtered.slice(-count);
|
|
1922
|
+
if (lines.length === 0) {
|
|
1923
|
+
ctx.ui.notify(`No logs matched (level=${filterLevel || "any"} reqId=${filterReqId || "any"} count=${count})`, "warning");
|
|
1630
1924
|
return;
|
|
1631
1925
|
}
|
|
1632
|
-
const
|
|
1633
|
-
|
|
1634
|
-
ctx.ui.notify(`pi-freeflow logs (last 25 lines from ${LOG_FILE}):\n\n${lines.join("\n")}`, "info");
|
|
1926
|
+
const header = `pi-freeflow logs (last ${lines.length}/${filtered.length} matched, total ${allLines.length} lines, file: ${LOG_FILE}${filterLevel ? ` level=${filterLevel}` : ""}${filterReqId ? ` req=${filterReqId}` : ""}):`;
|
|
1927
|
+
ctx.ui.notify(`${header}\n\n${lines.join("\n")}`, "info");
|
|
1635
1928
|
} catch (e) {
|
|
1636
1929
|
ctx.ui.notify(`Could not read log file: ${(e as Error).message}`, "error");
|
|
1637
1930
|
}
|
|
@@ -1745,11 +2038,27 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1745
2038
|
ui.setStatus?.("bansos", undefined);
|
|
1746
2039
|
}
|
|
1747
2040
|
|
|
1748
|
-
// Reload persisted state on session start/resume and show status if active
|
|
2041
|
+
// Reload persisted state on session start/resume and show status ONLY if active model is FreeFlow
|
|
1749
2042
|
pi.on?.("session_start", async (_event, ctx) => {
|
|
1750
2043
|
relayState = resolveRelayState();
|
|
1751
2044
|
statusUi = ctx.ui;
|
|
1752
|
-
|
|
2045
|
+
|
|
2046
|
+
const activeModel =
|
|
2047
|
+
ctx && typeof ctx === "object" && "model" in ctx
|
|
2048
|
+
? (ctx as { model?: { provider?: string; id?: string } }).model
|
|
2049
|
+
: null;
|
|
2050
|
+
const provider = activeModel?.provider;
|
|
2051
|
+
const modelId = activeModel?.id;
|
|
2052
|
+
const isFreeFlow =
|
|
2053
|
+
provider === "freeflow" ||
|
|
2054
|
+
Boolean(modelId && aliveCatalog.some((m) => m.id === modelId));
|
|
2055
|
+
|
|
2056
|
+
if (isFreeFlow) {
|
|
2057
|
+
updateStatusBar(ctx.ui);
|
|
2058
|
+
} else {
|
|
2059
|
+
// Relay status OFF / cleared when non-freeflow model is active in session
|
|
2060
|
+
ctx.ui?.setStatus?.("freeflow", undefined);
|
|
2061
|
+
}
|
|
1753
2062
|
});
|
|
1754
2063
|
|
|
1755
2064
|
// Update status bar immediately when user switches models
|
|
@@ -1757,16 +2066,18 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1757
2066
|
statusUi = ctx.ui;
|
|
1758
2067
|
const model =
|
|
1759
2068
|
event && typeof event === "object" && "model" in event
|
|
1760
|
-
? event.model
|
|
2069
|
+
? (event as { model?: { provider?: string; id?: string } }).model
|
|
1761
2070
|
: null;
|
|
1762
|
-
const provider =
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
2071
|
+
const provider = model?.provider;
|
|
2072
|
+
const modelId = model?.id;
|
|
2073
|
+
const isFreeFlow =
|
|
2074
|
+
provider === "freeflow" ||
|
|
2075
|
+
Boolean(modelId && aliveCatalog.some((m) => m.id === modelId));
|
|
2076
|
+
|
|
2077
|
+
if (isFreeFlow) {
|
|
2078
|
+
updateStatusBar(ctx.ui);
|
|
2079
|
+
} else {
|
|
2080
|
+
// Matikan status relay seketika jika model yang dipilih bukan model FreeFlow
|
|
1770
2081
|
ctx.ui?.setStatus?.("freeflow", undefined);
|
|
1771
2082
|
}
|
|
1772
2083
|
});
|