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.
Files changed (2) hide show
  1. package/extensions/index.ts +401 -90
  2. package/package.json +1 -1
@@ -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
- type LogLevel = "info" | "warn" | "error" | "audit";
691
- function log(level: LogLevel, message: string, meta?: Record<string, unknown>) {
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
- const ts = new Date().toISOString();
694
- const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
695
- const line = `[${ts}] [${level.toUpperCase()}] ${message}${metaStr}\n`;
696
- try {
697
- if (fs.existsSync(LOG_FILE) && fs.statSync(LOG_FILE).size > 2 * 1024 * 1024) {
698
- const backup = `${LOG_FILE}.1`;
699
- try { if (fs.existsSync(backup)) fs.unlinkSync(backup); } catch {}
700
- fs.renameSync(LOG_FILE, backup);
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
- } catch {}
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
- // 1. Tool choice & empty tools normalization
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
- // 2. Reasoning normalization
1002
- const modelId = typeof body.model === "string" ? body.model : "";
1003
- const isResponsesApi = modelId === "muse-spark-1.2-contributor-free";
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 re = body.reasoning_effort.toLowerCase();
1007
- if (re === "xhigh" || re === "max") {
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 = "low";
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 re = r.effort.toLowerCase();
1025
- if (re === "xhigh" || re === "max") {
1026
- r.effort = "xhigh"; // OpenCode Zen Responses API strictly requires "xhigh"
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 = "low";
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
- res.write(chunk);
1077
- if (typeof (res as any).flush === "function") {
1078
- (res as any).flush();
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 import("stream/web").ReadableStream,
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 import("stream/web").ReadableStream,
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
- error: String(e),
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
- let directBody = Buffer.concat(bodyChunks);
1296
- if (parsedBody) {
1297
- const directBodyObj = structuredClone(parsedBody);
1298
- normalizeRequestBody(directBodyObj, false, isKilo);
1299
- directBody = Buffer.from(JSON.stringify(directBodyObj));
1300
- }
1301
- const fwd = sanitizeHeaders(req.headers, target.hostname);
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: toggle relay egress live (on|off|status|url [URL]) ───
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 === "logs" || sub === "log") {
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
- if (!fs.existsSync(LOG_FILE)) {
1629
- ctx.ui.notify("Log file is empty", "info");
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 content = fs.readFileSync(LOG_FILE, "utf8");
1633
- const lines = content.trim().split("\n").slice(-25);
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
- updateStatusBar(ctx.ui);
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
- model && typeof model === "object" && "provider" in model
1764
- ? String(model.provider)
1765
- : null;
1766
- if (provider === "freeflow") {
1767
- updateStatusBar(ctx.ui, "freeflow");
1768
- } else if (provider) {
1769
- // Clear status when switched to other providers (e.g. anthropic, openai)
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
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.1.6",
4
+ "version": "1.1.8",
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",