pi-freeflow 1.1.7 → 1.1.9

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 +405 -78
  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,153 @@ 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
+ let cachedDebugState: DebugState | null | undefined = undefined;
739
+ let cachedDebugMtime = 0;
740
+ let cachedDebugAt = 0;
741
+ function loadDebugState(): DebugState | null {
742
+ const now = Date.now();
743
+ // cache for 1s to avoid per-chunk FS hit in hot pipe path
744
+ if (cachedDebugState !== undefined && now - cachedDebugAt < 1000) {
745
+ return cachedDebugState;
746
+ }
692
747
  try {
693
- const ts = new Date().toISOString();
694
- const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
695
- const line = `[${ts}] [${level.toUpperCase()}] ${message}${metaStr}\n`;
748
+ if (!fs.existsSync(DEBUG_STATE_FILE)) {
749
+ cachedDebugState = null;
750
+ cachedDebugAt = now;
751
+ return null;
752
+ }
753
+ const stat = fs.statSync(DEBUG_STATE_FILE);
754
+ if (stat.mtimeMs === cachedDebugMtime && cachedDebugState !== undefined) {
755
+ cachedDebugAt = now;
756
+ return cachedDebugState;
757
+ }
758
+ const raw = fs.readFileSync(DEBUG_STATE_FILE, "utf8");
759
+ const d = JSON.parse(raw) as DebugState;
760
+ if (typeof d.debug === "boolean") {
761
+ cachedDebugState = d;
762
+ cachedDebugMtime = stat.mtimeMs;
763
+ cachedDebugAt = now;
764
+ return d;
765
+ }
766
+ } catch {}
767
+ cachedDebugState = null;
768
+ cachedDebugAt = now;
769
+ return null;
770
+ }
771
+ function saveDebugState(s: DebugState): void {
772
+ try {
773
+ const dir = path.dirname(DEBUG_STATE_FILE);
774
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
775
+ const tmp = `${DEBUG_STATE_FILE}.${randomUUID()}.tmp`;
776
+ fs.writeFileSync(tmp, JSON.stringify(s, null, 2), "utf8");
777
+ fs.renameSync(tmp, DEBUG_STATE_FILE);
778
+ // update cache
696
779
  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);
780
+ const stat = fs.statSync(DEBUG_STATE_FILE);
781
+ cachedDebugState = s;
782
+ cachedDebugMtime = stat.mtimeMs;
783
+ cachedDebugAt = Date.now();
784
+ } catch {
785
+ cachedDebugState = s;
786
+ cachedDebugAt = Date.now();
787
+ }
788
+ } catch {}
789
+ }
790
+ function getMinLogLevel(): number {
791
+ const dbg = loadDebugState();
792
+ if (dbg?.debug) return LOG_LEVEL_ORDER.debug;
793
+ if (dbg?.level && dbg.level in LOG_LEVEL_ORDER) return LOG_LEVEL_ORDER[dbg.level];
794
+ const raw = (
795
+ process.env.FREEFLOW_LOG_LEVEL ||
796
+ process.env.BANSOS_LOG_LEVEL ||
797
+ "info"
798
+ ).toLowerCase();
799
+ if (raw in LOG_LEVEL_ORDER) return LOG_LEVEL_ORDER[raw as LogLevel];
800
+ if (process.env.FREEFLOW_DEBUG === "1" || process.env.FREEFLOW_DEBUG === "true") {
801
+ return LOG_LEVEL_ORDER.debug;
802
+ }
803
+ return LOG_LEVEL_ORDER.info;
804
+ }
805
+ function shouldLog(level: LogLevel): boolean {
806
+ return LOG_LEVEL_ORDER[level] >= getMinLogLevel();
807
+ }
808
+ function isDebugEnabled(): boolean {
809
+ return LOG_LEVEL_ORDER.debug >= getMinLogLevel();
810
+ }
811
+ function rotateLogsIfNeeded(): void {
812
+ try {
813
+ if (!fs.existsSync(LOG_FILE)) return;
814
+ if (fs.statSync(LOG_FILE).size <= LOG_MAX_BYTES) return;
815
+ for (let i = LOG_MAX_FILES - 1; i >= 1; i--) {
816
+ const src = i === 1 ? LOG_FILE : `${LOG_FILE}.${i - 1}`;
817
+ const dst = `${LOG_FILE}.${i}`;
818
+ try {
819
+ if (fs.existsSync(src)) {
820
+ if (fs.existsSync(dst)) fs.unlinkSync(dst);
821
+ fs.renameSync(src, dst);
822
+ }
823
+ } catch {}
824
+ }
825
+ } catch {}
826
+ }
827
+ function formatLogMeta(
828
+ meta?: Record<string, unknown>,
829
+ reqId?: string,
830
+ ): string {
831
+ const parts: string[] = [];
832
+ if (reqId) parts.push(`req=${reqId}`);
833
+ if (meta && Object.keys(meta).length > 0) {
834
+ const safe: Record<string, unknown> = {};
835
+ for (const [k, v] of Object.entries(meta)) {
836
+ if (typeof v === "string" && v.length > 800) {
837
+ safe[k] = `${v.slice(0, 800)}…(${v.length})`;
838
+ } else {
839
+ safe[k] = v;
701
840
  }
702
- } catch {}
841
+ }
842
+ parts.push(JSON.stringify(safe));
843
+ }
844
+ return parts.length ? ` ${parts.join(" ")}` : "";
845
+ }
846
+ function log(
847
+ level: LogLevel,
848
+ message: string,
849
+ meta?: Record<string, unknown>,
850
+ reqId?: string,
851
+ ): void {
852
+ if (!shouldLog(level)) return;
853
+ try {
854
+ const ts = new Date().toISOString();
855
+ const line = `[${ts}] [${level.toUpperCase()}]${reqId ? ` [${reqId}]` : ""} ${message}${formatLogMeta(meta, undefined)}\n`;
856
+ rotateLogsIfNeeded();
703
857
  fs.appendFileSync(LOG_FILE, line, "utf8");
704
858
  } catch {}
705
859
  }
860
+ function logDebug(
861
+ message: string,
862
+ meta?: Record<string, unknown>,
863
+ reqId?: string,
864
+ ): void {
865
+ log("debug", message, meta, reqId);
866
+ }
706
867
 
707
868
  // ── Rate Limiter ───────────────────────────────────────────────────
708
869
  // Kilo documents 200 free requests/hour/IP. OpenCode owns its own daily quota;
@@ -984,8 +1145,24 @@ function normalizeRequestBody(
984
1145
  body: Record<string, unknown>,
985
1146
  isRelay = false,
986
1147
  isKilo = false,
1148
+ reqId?: string,
987
1149
  ): Record<string, unknown> {
988
- // 1. Tool choice & empty tools normalization
1150
+ const DBG = isDebugEnabled();
1151
+ const modelId = typeof body.model === "string" ? body.model : "";
1152
+ const modelDef = MODEL_MAP.get(modelId);
1153
+ const isResponsesApi = modelId === "muse-spark-1.2-contributor-free";
1154
+
1155
+ if (DBG) {
1156
+ log("debug", `normalize: incoming model=${modelId} kilo=${isKilo} relay=${isRelay}`, {
1157
+ reasoning_effort: body.reasoning_effort,
1158
+ reasoning: body.reasoning,
1159
+ thinking: (body as Record<string, unknown>).thinking,
1160
+ tool_choice: body.tool_choice,
1161
+ toolsLen: Array.isArray(body.tools) ? body.tools.length : undefined,
1162
+ }, reqId);
1163
+ }
1164
+
1165
+ // 1. Tool choice & empty tools normalization (pi-ai compat: opencode only supports auto)
989
1166
  if (Array.isArray(body.tools) && body.tools.length === 0) {
990
1167
  delete body.tools;
991
1168
  delete body.tool_choice;
@@ -994,26 +1171,57 @@ function normalizeRequestBody(
994
1171
  delete body.tool_choice;
995
1172
  delete body.tools;
996
1173
  } else if (!isKilo && body.tool_choice && body.tool_choice !== "auto") {
997
- // OpenCode Zen only supports "auto" or undefined
998
1174
  body.tool_choice = "auto";
999
1175
  }
1000
1176
 
1001
- // 2. Reasoning normalization
1002
- const modelId = typeof body.model === "string" ? body.model : "";
1003
- const isResponsesApi = modelId === "muse-spark-1.2-contributor-free";
1177
+ // 1b. Anthropic thinking -> OpenAI reasoning_effort auto-translate
1178
+ // Pi sends anthropic `thinking: {type:"enabled",budget_tokens}` when provider is anthropic.
1179
+ // Our proxy is always openai-completions/responses upstream, so translate.
1180
+ // Ref: pi-ai api/anthropic-messages.js (thinking.type adaptive/enabled/disabled) -> api/openai-completions.js (reasoning_effort)
1181
+ const thinkingRaw = (body as Record<string, unknown>).thinking;
1182
+ if (thinkingRaw && typeof thinkingRaw === "object") {
1183
+ const th = thinkingRaw as Record<string, unknown>;
1184
+ if (th.type === "disabled") {
1185
+ delete (body as Record<string, unknown>).thinking;
1186
+ // Mark as off so downstream reasoning mapping can clear effort
1187
+ if (!body.reasoning_effort && !body.reasoning) {
1188
+ body.reasoning_effort = "off";
1189
+ }
1190
+ } else if (th.type === "enabled" || th.type === "adaptive") {
1191
+ delete (body as Record<string, unknown>).thinking;
1192
+ // Preserve budget as hint if no explicit effort set
1193
+ if (!body.reasoning_effort && typeof th.budget_tokens === "number") {
1194
+ const budget = th.budget_tokens as number;
1195
+ if (budget >= 8000) body.reasoning_effort = "xhigh";
1196
+ else if (budget >= 4000) body.reasoning_effort = "high";
1197
+ else if (budget >= 2000) body.reasoning_effort = "medium";
1198
+ else body.reasoning_effort = "low";
1199
+ }
1200
+ }
1201
+ }
1202
+
1203
+ // 2. Reasoning normalization — per-model thinkingLevelMap aware
1204
+ // Ref: pi-ai api/openai-completions.js (compat.thinkingFormat branches) + api/openai-responses-shared.js
1205
+ const mapEffort = (rawEffort: string): string | null | undefined => {
1206
+ const key = rawEffort.toLowerCase() as keyof NonNullable<ModelDef["thinkingLevelMap"]>;
1207
+ if (modelDef?.thinkingLevelMap && key in modelDef.thinkingLevelMap) {
1208
+ return modelDef.thinkingLevelMap[key] as string | null;
1209
+ }
1210
+ if (rawEffort === "xhigh" || rawEffort === "max") {
1211
+ return isResponsesApi ? "xhigh" : (modelId === "x-preview-f-free" ? "max" : "xhigh");
1212
+ }
1213
+ if (rawEffort === "high" || rawEffort === "medium") return "high";
1214
+ if (rawEffort === "minimal") return "minimal";
1215
+ if (rawEffort === "none" || rawEffort === "off") return null;
1216
+ return "low";
1217
+ };
1004
1218
 
1005
1219
  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") {
1220
+ const mapped = mapEffort(body.reasoning_effort);
1221
+ if (mapped === null || mapped === undefined) {
1014
1222
  delete body.reasoning_effort;
1015
1223
  } else {
1016
- body.reasoning_effort = "low";
1224
+ body.reasoning_effort = mapped;
1017
1225
  }
1018
1226
  }
1019
1227
  if (body.reasoning && typeof body.reasoning === "object") {
@@ -1021,21 +1229,17 @@ function normalizeRequestBody(
1021
1229
  if (r.effort === "none" || r.effort === "off") {
1022
1230
  delete r.effort;
1023
1231
  } 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";
1232
+ const mapped = mapEffort(r.effort);
1233
+ if (mapped === null || mapped === undefined) {
1234
+ delete r.effort;
1031
1235
  } else {
1032
- r.effort = "low";
1236
+ r.effort = mapped;
1033
1237
  }
1034
1238
  }
1239
+ if (isResponsesApi && r.effort === "max") r.effort = "xhigh";
1035
1240
  }
1036
1241
 
1037
1242
  // 3. Max & Min tokens clamping (model-specific clamp + Vercel relay clamp)
1038
- const modelDef = MODEL_MAP.get(modelId);
1039
1243
  const modelMax = modelDef?.maxTokens ?? RELAY_MAX_TOKENS;
1040
1244
  const clampTokens = (val: number): number => {
1041
1245
  let clamped = val;
@@ -1055,16 +1259,47 @@ function normalizeRequestBody(
1055
1259
  body.max_output_tokens = clampTokens(body.max_output_tokens);
1056
1260
  }
1057
1261
 
1262
+ if (DBG) {
1263
+ log("debug", `normalize: outgoing model=${modelId}`, {
1264
+ reasoning_effort: body.reasoning_effort,
1265
+ reasoning: body.reasoning,
1266
+ max_tokens: body.max_tokens,
1267
+ max_output_tokens: body.max_output_tokens,
1268
+ }, reqId);
1269
+ }
1270
+
1058
1271
  return body;
1059
1272
  }
1060
1273
 
1061
1274
  // ponytail: shared stream pipe — upstream abort/timeout must end response,
1062
1275
  // not become an uncaught exception that crashes pi.
1276
+ // Adds lightweight thinking-tag sniffing for debug audit (no payload mutation).
1277
+ // Ref: pi-ai api/openai-completions.js (thinkingDelta) + api/openai-responses-shared.js (reasoning)
1063
1278
  function pipeUpstreamStream(
1064
1279
  nodeStream: Readable,
1065
1280
  res: http.ServerResponse,
1066
1281
  req: http.IncomingMessage,
1282
+ reqId?: string,
1067
1283
  ): void {
1284
+ const rid = reqId || randomUUID().slice(0, 8);
1285
+ let totalChunks = 0;
1286
+ let totalBytes = 0;
1287
+ let thinkingChunks = 0;
1288
+ let thinkingBytes = 0;
1289
+ let firstChunkAt: number | null = null;
1290
+ const startAt = Date.now();
1291
+ const sniffThinking = (chunk: Buffer | string): boolean => {
1292
+ const s = typeof chunk === "string" ? chunk : chunk.toString("utf8", 0, Math.min(chunk.length, 4000));
1293
+ return (
1294
+ s.includes("reasoning") ||
1295
+ s.includes("thinking") ||
1296
+ s.includes("<think>") ||
1297
+ s.includes("reasoning_content") ||
1298
+ s.includes("\"type\":\"thinking\"") ||
1299
+ s.includes("thinking_delta")
1300
+ );
1301
+ };
1302
+
1068
1303
  try {
1069
1304
  if (typeof res.flushHeaders === "function") {
1070
1305
  res.flushHeaders();
@@ -1073,15 +1308,29 @@ function pipeUpstreamStream(
1073
1308
 
1074
1309
  nodeStream.on("data", (chunk: Buffer | string) => {
1075
1310
  try {
1076
- res.write(chunk);
1077
- if (typeof (res as any).flush === "function") {
1078
- (res as any).flush();
1311
+ if (firstChunkAt === null) {
1312
+ firstChunkAt = Date.now();
1313
+ const ttfb = firstChunkAt - startAt;
1314
+ log("debug", `stream first chunk in ${ttfb}ms`, undefined, rid);
1079
1315
  }
1316
+ totalChunks++;
1317
+ totalBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
1318
+ if (sniffThinking(chunk)) {
1319
+ thinkingChunks++;
1320
+ thinkingBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
1321
+ if (isDebugEnabled() && thinkingChunks <= 3) {
1322
+ const preview = typeof chunk === "string" ? chunk.slice(0, 600) : chunk.toString("utf8", 0, 600);
1323
+ log("debug", `thinking chunk #${thinkingChunks}`, { preview: preview.slice(0, 400) }, rid);
1324
+ }
1325
+ }
1326
+ res.write(chunk);
1327
+ const maybeFlush = res as unknown as { flush?: () => void };
1328
+ if (typeof maybeFlush.flush === "function") maybeFlush.flush();
1080
1329
  } catch {}
1081
1330
  });
1082
1331
 
1083
1332
  nodeStream.on("error", (e: unknown) => {
1084
- log("error", "upstream stream error", { error: String(e) });
1333
+ log("error", "upstream stream error", { error: String(e), totalChunks, thinkingChunks }, rid);
1085
1334
  try {
1086
1335
  if (!res.headersSent) {
1087
1336
  res.writeHead(502, { "content-type": "application/json" });
@@ -1092,6 +1341,12 @@ function pipeUpstreamStream(
1092
1341
  } catch {}
1093
1342
  });
1094
1343
  nodeStream.on("end", () => {
1344
+ const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
1345
+ if (thinkingChunks > 0) {
1346
+ log("info", `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes/1024).toFixed(1)}KB), thinking: ${thinkingChunks} chunks (${(thinkingBytes/1024).toFixed(1)}KB)`, undefined, rid);
1347
+ } else if (isDebugEnabled()) {
1348
+ log("debug", `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes/1024).toFixed(1)}KB), no thinking detected`, undefined, rid);
1349
+ }
1095
1350
  try {
1096
1351
  if (!res.writableEnded) res.end();
1097
1352
  } catch {}
@@ -1102,6 +1357,7 @@ function pipeUpstreamStream(
1102
1357
  } catch {}
1103
1358
  });
1104
1359
  req.on("aborted", () => {
1360
+ log("warn", "client aborted — destroying upstream", { totalChunks }, rid);
1105
1361
  if (!nodeStream.destroyed) nodeStream.destroy();
1106
1362
  });
1107
1363
  req.on("close", () => {
@@ -1123,7 +1379,10 @@ function startProxy(
1123
1379
 
1124
1380
  const server = http.createServer((req, res) => {
1125
1381
  const clientIP = getClientIP(req);
1126
-
1382
+ const reqId = randomUUID().slice(0, 8);
1383
+ if (isDebugEnabled()) {
1384
+ log("debug", `incoming ${req.method} ${req.url} from ${clientIP}`, { ip: clientIP, method: req.method, url: req.url }, reqId);
1385
+ }
1127
1386
  if (!ALLOWED_METHODS.has(req.method ?? "")) {
1128
1387
  res.writeHead(405, { "content-type": "application/json" });
1129
1388
  res.end(JSON.stringify({ error: "method not allowed" }));
@@ -1173,7 +1432,7 @@ function startProxy(
1173
1432
  // Read body to detect model for routing
1174
1433
  const bodyChunks: Buffer[] = [];
1175
1434
  req.on("error", (err) => {
1176
- log("warn", "client request error during body buffering", { error: String(err) });
1435
+ log("warn", "client request error during body buffering", { error: String(err) }, reqId);
1177
1436
  if (!res.headersSent) res.writeHead(400, { "content-type": "application/json" });
1178
1437
  res.end(JSON.stringify({ error: "bad request" }));
1179
1438
  });
@@ -1194,13 +1453,17 @@ function startProxy(
1194
1453
  } catch {}
1195
1454
 
1196
1455
  const upstream: Upstream = isKilo ? "kilo" : "opencode";
1456
+ if (!checkRateLimit(clientIP, upstream)) {
1457
+ log("warn", `rate limit hit for ${upstream} from ${clientIP}`, { ip: clientIP, upstream }, reqId);
1458
+ res.writeHead(429, { "content-type": "application/json" });
1459
+ res.end(JSON.stringify({ error: "rate limit exceeded" }));
1460
+ return;
1461
+ }
1197
1462
 
1198
1463
  try {
1199
1464
  if (isKilo && parsedBody) {
1200
- // KiloCode gateway routing (free models are keyless)
1201
- const isStream = parsedBody.stream === true;
1202
1465
  const kiloBodyObj = structuredClone(parsedBody);
1203
- normalizeRequestBody(kiloBodyObj, true, isKilo);
1466
+ normalizeRequestBody(kiloBodyObj, true, isKilo, reqId);
1204
1467
  const response = await relayFetch(KILO_CHAT_URL, {
1205
1468
  method: "POST",
1206
1469
  headers: {
@@ -1209,7 +1472,7 @@ function startProxy(
1209
1472
  },
1210
1473
  body: JSON.stringify(kiloBodyObj),
1211
1474
  signal: AbortSignal.timeout(300_000),
1212
- });
1475
+ }, reqId);
1213
1476
  if (isStream && response.ok && response.body) {
1214
1477
  const ct =
1215
1478
  response.headers.get("content-type") || "text/event-stream";
@@ -1221,10 +1484,11 @@ function startProxy(
1221
1484
  });
1222
1485
  pipeUpstreamStream(
1223
1486
  Readable.fromWeb(
1224
- response.body as unknown as import("stream/web").ReadableStream,
1487
+ response.body as unknown as WebReadableStream,
1225
1488
  ),
1226
1489
  res,
1227
1490
  req,
1491
+ reqId,
1228
1492
  );
1229
1493
  } else {
1230
1494
  const data = await response.text();
@@ -1244,12 +1508,9 @@ function startProxy(
1244
1508
  activeHost,
1245
1509
  );
1246
1510
  try {
1247
- let relayBody = bodyChunks.length
1248
- ? Buffer.concat(bodyChunks)
1249
- : undefined;
1250
1511
  if (relayBody && parsedBody) {
1251
1512
  const relayBodyObj = structuredClone(parsedBody);
1252
- normalizeRequestBody(relayBodyObj, true, isKilo);
1513
+ normalizeRequestBody(relayBodyObj, true, isKilo, reqId);
1253
1514
  relayBody = Buffer.from(JSON.stringify(relayBodyObj));
1254
1515
  }
1255
1516
  const response = await relayFetch(fullUrl, {
@@ -1257,7 +1518,7 @@ function startProxy(
1257
1518
  headers: relayHeaders,
1258
1519
  body: relayBody,
1259
1520
  signal: AbortSignal.timeout(300_000),
1260
- });
1521
+ }, reqId);
1261
1522
  const ct =
1262
1523
  response.headers.get("content-type") || "application/json";
1263
1524
  if (response.ok && response.body) {
@@ -1271,10 +1532,11 @@ function startProxy(
1271
1532
  });
1272
1533
  pipeUpstreamStream(
1273
1534
  Readable.fromWeb(
1274
- response.body as unknown as import("stream/web").ReadableStream,
1535
+ response.body as unknown as WebReadableStream,
1275
1536
  ),
1276
1537
  res,
1277
1538
  req,
1539
+ reqId,
1278
1540
  );
1279
1541
  } else {
1280
1542
  const data = await response.text();
@@ -1286,19 +1548,23 @@ function startProxy(
1286
1548
  return; // relay handled the response
1287
1549
  } catch (e) {
1288
1550
  log("warn", "opencode relay failed, falling back to direct", {
1289
- error: String(e),
1290
- });
1551
+ error: String(e),
1552
+ }, reqId);
1291
1553
  if (res.headersSent) return; // can't recover mid-stream
1292
1554
  }
1293
1555
  }
1294
1556
  // 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);
1557
+ // direct path — with debug trace and thinking-aware normalize
1558
+ let directBody = Buffer.concat(bodyChunks);
1559
+ if (parsedBody) {
1560
+ const directBodyObj = structuredClone(parsedBody);
1561
+ normalizeRequestBody(directBodyObj, false, isKilo, reqId);
1562
+ directBody = Buffer.from(JSON.stringify(directBodyObj));
1563
+ }
1564
+ if (isDebugEnabled()) {
1565
+ log("debug", `direct upstream ${target.hostname}${target.pathname} (${directBody.length}B)`, { model: parsedBody?.model, isKilo }, reqId);
1566
+ }
1567
+ const fwd = sanitizeHeaders(req.headers, target.hostname);
1302
1568
  if (directBody.length > 0) {
1303
1569
  fwd["content-length"] = String(directBody.byteLength);
1304
1570
  }
@@ -1323,14 +1589,14 @@ function startProxy(
1323
1589
  outHeaders["x-content-type-options"] = "nosniff";
1324
1590
  res.writeHead(upstream.statusCode ?? 502, outHeaders);
1325
1591
  upstream.on("error", (streamErr) => {
1326
- log("error", "upstream stream error in direct proxy", { error: String(streamErr) });
1592
+ log("error", "upstream stream error in direct proxy", { error: String(streamErr) }, reqId);
1327
1593
  if (!res.writableEnded) res.end();
1328
1594
  });
1329
1595
  upstream.pipe(res);
1330
1596
  },
1331
1597
  );
1332
1598
  proxy.on("error", (proxyErr) => {
1333
- log("error", "proxy socket error", { error: String(proxyErr) });
1599
+ log("error", "proxy socket error", { error: String(proxyErr) }, reqId);
1334
1600
  if (!res.headersSent) {
1335
1601
  res.writeHead(502, { "content-type": "application/json" });
1336
1602
  res.end(JSON.stringify({ error: "upstream error" }));
@@ -1355,7 +1621,7 @@ function startProxy(
1355
1621
  proxy.end(directBody);
1356
1622
  }
1357
1623
  } catch (err) {
1358
- log("error", "proxy error", { error: String(err) });
1624
+ log("error", "proxy error", { error: String(err) }, reqId);
1359
1625
  if (!res.headersSent)
1360
1626
  res.writeHead(502, { "content-type": "application/json" });
1361
1627
  res.end(JSON.stringify({ error: "internal error" }));
@@ -1480,12 +1746,12 @@ export default async function (pi: ExtensionAPI) {
1480
1746
 
1481
1747
  pi.registerProvider("freeflow", providerConfig);
1482
1748
  }
1483
- // ── /bansos command: toggle relay egress live (on|off|status|url [URL]) ───
1749
+ // ── /bansos command: relay + debug + logs ───
1484
1750
  const commandSpec = {
1485
1751
  description:
1486
- "Relay egress: on | off | status | logs | url [URL] | deploy | list | use <URL> | remove <URL> | refresh",
1752
+ "Relay egress: on | off | status | logs [level] [n] | debug on|off|status | url [URL] | deploy | list | use <URL> | remove <URL> | refresh",
1487
1753
  getArgumentCompletions: (prefix: string) =>
1488
- ["on", "off", "status", "url", "deploy", "list", "use", "remove", "refresh", "models"]
1754
+ ["on", "off", "status", "url", "deploy", "list", "use", "remove", "refresh", "models", "logs", "debug", "trace"]
1489
1755
  .filter((s) => s.startsWith(prefix))
1490
1756
  .map((s) => ({ value: s, label: s })),
1491
1757
  handler: async (args: string, ctx) => {
@@ -1623,15 +1889,76 @@ export default async function (pi: ExtensionAPI) {
1623
1889
  setRelay(true, url, "manual");
1624
1890
  persist();
1625
1891
  flash();
1626
- } else if (sub === "logs" || sub === "log") {
1892
+ } else if (sub === "debug") {
1893
+ const arg = rest.trim().toLowerCase();
1894
+ if (arg === "on" || arg === "enable" || arg === "true") {
1895
+ saveDebugState({ debug: true });
1896
+ ctx.ui.notify("🔍 Debug ON — verbose trace enabled (level=debug). Logs now include request IDs, thinking sniffing, and payload normalize details.", "info");
1897
+ } else if (arg === "off" || arg === "disable" || arg === "false") {
1898
+ saveDebugState({ debug: false });
1899
+ ctx.ui.notify("🔇 Debug OFF — level restored to info. File: " + DEBUG_STATE_FILE, "info");
1900
+ } else if (arg.startsWith("level")) {
1901
+ const lvl = arg.split(/\s+/)[1] as LogLevel | undefined;
1902
+ if (lvl && lvl in LOG_LEVEL_ORDER) {
1903
+ saveDebugState({ debug: false, level: lvl });
1904
+ ctx.ui.notify(`Log level set to ${lvl} (persisted to ${DEBUG_STATE_FILE})`, "info");
1905
+ } else {
1906
+ ctx.ui.notify(`Unknown level: ${lvl} (use debug/info/warn/error)`, "warn");
1907
+ }
1908
+ } else {
1909
+ const st = loadDebugState();
1910
+ const cur = st?.debug ? "debug (ON)" : (st?.level || process.env.FREEFLOW_LOG_LEVEL || "info");
1911
+ ctx.ui.notify(`Debug status: ${cur}\nFile: ${DEBUG_STATE_FILE}\nMinLevel: ${getMinLogLevel()} | isDebug=${isDebugEnabled()}\nUsage: /freeflow debug on|off | /freeflow debug level debug`, "info");
1912
+ }
1913
+ } else if (sub === "logs" || sub === "log" || sub === "trace") {
1627
1914
  try {
1628
- if (!fs.existsSync(LOG_FILE)) {
1629
- ctx.ui.notify("Log file is empty", "info");
1915
+ const rawRest = rest.trim();
1916
+ let filterLevel: LogLevel | null = null;
1917
+ let filterReqId: string | null = null;
1918
+ let count = 25;
1919
+ // trace mode: sub === trace or rest starts with trace/req
1920
+ if (sub === "trace" && rawRest) {
1921
+ filterReqId = rawRest.split(/\s+/)[0];
1922
+ } else if (rawRest) {
1923
+ const tokens = rawRest.split(/\s+/);
1924
+ for (const t of tokens) {
1925
+ const lower = t.toLowerCase();
1926
+ if (lower in LOG_LEVEL_ORDER) filterLevel = lower as LogLevel;
1927
+ else if (/^\d+$/.test(t)) count = Math.min(200, Math.max(5, parseInt(t, 10)));
1928
+ else if (/^[a-f0-9]{6,8}$/i.test(t) || t.startsWith("req=")) filterReqId = t.replace(/^req=/, "");
1929
+ else if (lower === "trace" || lower === "req") continue;
1930
+ else filterReqId = t;
1931
+ }
1932
+ }
1933
+ const files: string[] = [LOG_FILE, `${LOG_FILE}.1`, `${LOG_FILE}.2`].filter((f) => fs.existsSync(f));
1934
+ if (files.length === 0) {
1935
+ ctx.ui.notify("Log file is empty (no logs yet)", "info");
1936
+ return;
1937
+ }
1938
+ let allLines: string[] = [];
1939
+ for (const f of files) {
1940
+ try {
1941
+ const c = fs.readFileSync(f, "utf8");
1942
+ const ls = c.trim().split("\n").filter(Boolean);
1943
+ allLines = ls.concat(allLines);
1944
+ } catch {}
1945
+ }
1946
+ let filtered = allLines;
1947
+ if (filterLevel) {
1948
+ const want = `[${filterLevel.toUpperCase()}]`;
1949
+ filtered = filtered.filter((l) => l.includes(want));
1950
+ }
1951
+ if (filterReqId) {
1952
+ // match [reqId] bracket or req= prefix
1953
+ filtered = filtered.filter((l) => l.includes(filterReqId as string) || l.includes(`[${filterReqId}]`));
1954
+ }
1955
+ const lines = filtered.slice(-count);
1956
+ if (lines.length === 0) {
1957
+ ctx.ui.notify(`No logs matched (level=${filterLevel || "any"} reqId=${filterReqId || "any"} count=${count})`, "warning");
1630
1958
  return;
1631
1959
  }
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");
1960
+ const header = `pi-freeflow logs (last ${lines.length}/${filtered.length} matched, total ${allLines.length} lines, file: ${LOG_FILE}${filterLevel ? ` level=${filterLevel}` : ""}${filterReqId ? ` req=${filterReqId}` : ""}):`;
1961
+ ctx.ui.notify(`${header}\n\n${lines.join("\n")}`, "info");
1635
1962
  } catch (e) {
1636
1963
  ctx.ui.notify(`Could not read log file: ${(e as Error).message}`, "error");
1637
1964
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.1.7",
4
+ "version": "1.1.9",
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",