pi-freeflow 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +7 -11
- package/extensions/index.ts +4 -2137
- package/package.json +30 -5
- package/src/catalog.ts +204 -0
- package/src/commands.ts +448 -0
- package/src/config.ts +145 -0
- package/src/deploy.ts +126 -0
- package/src/index.ts +244 -0
- package/src/logger.ts +327 -0
- package/src/models.ts +343 -0
- package/src/proxy.ts +473 -0
- package/src/rate-limiter.ts +148 -0
- package/src/relay-state.ts +218 -0
- package/src/relay.ts +193 -0
- package/src/stream-pipe.ts +154 -0
- package/src/types.ts +164 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent relay state manager and failover ordering for pi-freeflow
|
|
3
|
+
*
|
|
4
|
+
* Handles atomic state writes to ~/.pi/agent/pi-freeflow-relay-state.json.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { DEFAULT_RELAY_URL, RELAY_STATE_FILE } from "./config.ts";
|
|
11
|
+
import { logWarn } from "./logger.ts";
|
|
12
|
+
import type { ExtensionUIContext, KnownRelay, RelayState } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load persisted relay state from disk.
|
|
16
|
+
*/
|
|
17
|
+
export function loadRelayState(): RelayState {
|
|
18
|
+
try {
|
|
19
|
+
if (!fs.existsSync(RELAY_STATE_FILE)) {
|
|
20
|
+
return { enabled: true, url: DEFAULT_RELAY_URL, relays: [] };
|
|
21
|
+
}
|
|
22
|
+
const s = JSON.parse(fs.readFileSync(RELAY_STATE_FILE, "utf8"));
|
|
23
|
+
const relays: KnownRelay[] = Array.isArray(s?.relays) ? s.relays : [];
|
|
24
|
+
// Auto-on by default if saved relays exist, unless explicitly set to false
|
|
25
|
+
const enabled = s?.enabled !== undefined ? Boolean(s.enabled) : relays.length > 0;
|
|
26
|
+
return {
|
|
27
|
+
enabled,
|
|
28
|
+
url: typeof s?.url === "string" ? s.url.trim() : (relays[0]?.url || DEFAULT_RELAY_URL),
|
|
29
|
+
relays,
|
|
30
|
+
};
|
|
31
|
+
} catch {
|
|
32
|
+
return { enabled: true, url: DEFAULT_RELAY_URL, relays: [] };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Atomically save relay state to disk using a temporary file and rename.
|
|
38
|
+
*/
|
|
39
|
+
export function saveRelayState(s: RelayState): void {
|
|
40
|
+
try {
|
|
41
|
+
const dir = path.dirname(RELAY_STATE_FILE);
|
|
42
|
+
if (!fs.existsSync(dir)) {
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
const tmpPath = `${RELAY_STATE_FILE}.${randomUUID()}.tmp`;
|
|
46
|
+
fs.writeFileSync(tmpPath, JSON.stringify(s, null, 2), "utf8");
|
|
47
|
+
fs.renameSync(tmpPath, RELAY_STATE_FILE);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
logWarn("Could not persist relay state", { error: String(e) });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Deduplicate and add a relay URL to the known relay list.
|
|
55
|
+
*/
|
|
56
|
+
export function ensureRelay(s: RelayState, url: string, label?: string): void {
|
|
57
|
+
if (!url || s.relays.some((r) => r.url === url)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
s.relays.push({ url, label, addedAt: new Date().toISOString() });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Remove a relay URL from the known relay list.
|
|
65
|
+
*/
|
|
66
|
+
export function removeRelay(s: RelayState, url: string): void {
|
|
67
|
+
s.relays = s.relays.filter((r) => r.url !== url);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve relay state with defaults.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveRelayState(): RelayState {
|
|
74
|
+
const s = loadRelayState();
|
|
75
|
+
// Seed the relay list from defaults when state lacks entries.
|
|
76
|
+
if (!s.relays.length) {
|
|
77
|
+
if (DEFAULT_RELAY_URL) {
|
|
78
|
+
ensureRelay(s, DEFAULT_RELAY_URL, "Default");
|
|
79
|
+
}
|
|
80
|
+
if (s.url && s.url !== DEFAULT_RELAY_URL) {
|
|
81
|
+
ensureRelay(s, s.url, "previous");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!s.url) {
|
|
85
|
+
s.url = DEFAULT_RELAY_URL;
|
|
86
|
+
}
|
|
87
|
+
return s;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// In-memory global relay state
|
|
91
|
+
let activeRelayState: RelayState = resolveRelayState();
|
|
92
|
+
// Monotonic counter to distribute primary relay across concurrent subagents
|
|
93
|
+
let roundRobinCounter = 0;
|
|
94
|
+
let activeStatusUi: ExtensionUIContext | null = null;
|
|
95
|
+
/**
|
|
96
|
+
* Mtime of the on-disk state file at the moment we last read or wrote it.
|
|
97
|
+
* Lets worker processes pick up relay-pool changes persisted by another
|
|
98
|
+
* session's master daemon, while never clobbering this process's own
|
|
99
|
+
* unpersisted runtime overrides between external writes.
|
|
100
|
+
*/
|
|
101
|
+
let lastKnownStateMtimeMs = -1;
|
|
102
|
+
|
|
103
|
+
function currentDiskStateMtimeMs(): number {
|
|
104
|
+
try {
|
|
105
|
+
return fs.statSync(RELAY_STATE_FILE).mtimeMs;
|
|
106
|
+
} catch {
|
|
107
|
+
return -1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
lastKnownStateMtimeMs = currentDiskStateMtimeMs();
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get active in-memory relay state
|
|
115
|
+
*/
|
|
116
|
+
export function getActiveRelayState(): RelayState {
|
|
117
|
+
const currentMtime = currentDiskStateMtimeMs();
|
|
118
|
+
if (currentMtime > 0 && currentMtime > lastKnownStateMtimeMs) {
|
|
119
|
+
activeRelayState = resolveRelayState();
|
|
120
|
+
lastKnownStateMtimeMs = currentMtime;
|
|
121
|
+
}
|
|
122
|
+
return activeRelayState;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Set active in-memory relay state and persist to disk
|
|
127
|
+
*/
|
|
128
|
+
export function setActiveRelayState(s: RelayState, persist = true): void {
|
|
129
|
+
activeRelayState = s;
|
|
130
|
+
if (persist) {
|
|
131
|
+
saveRelayState(s);
|
|
132
|
+
}
|
|
133
|
+
lastKnownStateMtimeMs = currentDiskStateMtimeMs();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Register the Pi extension UI context for status line updates
|
|
138
|
+
*/
|
|
139
|
+
export function setStatusUi(ui: ExtensionUIContext | null): void {
|
|
140
|
+
activeStatusUi = ui;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get the current Pi extension UI context
|
|
145
|
+
*/
|
|
146
|
+
export function getStatusUi(): ExtensionUIContext | null {
|
|
147
|
+
return activeStatusUi;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Generate a short, human-readable label for a relay URL.
|
|
152
|
+
*/
|
|
153
|
+
export function shortRelayLabel(url: string, relays?: KnownRelay[]): string {
|
|
154
|
+
const pool = relays || activeRelayState.relays;
|
|
155
|
+
try {
|
|
156
|
+
const hit = pool.find((r) => r.url === url);
|
|
157
|
+
if (hit?.label) return hit.label;
|
|
158
|
+
return new URL(url).host.split(".")[0];
|
|
159
|
+
} catch {
|
|
160
|
+
return url.slice(0, 18);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get ordered candidate URLs for relay execution starting with the sticky active URL.
|
|
166
|
+
* Reloads from disk only when another process changed the state file (mtime moved),
|
|
167
|
+
* so cross-session relay-pool updates propagate to workers while this process's own
|
|
168
|
+
* unpersisted runtime overrides survive between external writes.
|
|
169
|
+
*/
|
|
170
|
+
export function getOrderedRelayUrls(): string[] {
|
|
171
|
+
const mtime = currentDiskStateMtimeMs();
|
|
172
|
+
if (mtime !== lastKnownStateMtimeMs) {
|
|
173
|
+
activeRelayState = loadRelayState();
|
|
174
|
+
lastKnownStateMtimeMs = mtime;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (activeRelayState.relays && activeRelayState.relays.length > 0) {
|
|
178
|
+
const active = (activeRelayState.url || "").trim();
|
|
179
|
+
let activeIdx = activeRelayState.relays.findIndex((r) => r.url === active);
|
|
180
|
+
if (activeIdx < 0) {
|
|
181
|
+
activeIdx = 0;
|
|
182
|
+
}
|
|
183
|
+
// Rotate starting point per-request to avoid thundering herd when many
|
|
184
|
+
// subagents hit the shared 127.0.0.1 daemon at once — each request
|
|
185
|
+
// tries a different primary relay, but still rolls seamlessly on 429.
|
|
186
|
+
const startIdx = (activeIdx + (roundRobinCounter++ % activeRelayState.relays.length)) % activeRelayState.relays.length;
|
|
187
|
+
const ordered: string[] = [];
|
|
188
|
+
for (let i = 0; i < activeRelayState.relays.length; i++) {
|
|
189
|
+
const r = activeRelayState.relays[(startIdx + i) % activeRelayState.relays.length];
|
|
190
|
+
if (r?.url?.trim()) {
|
|
191
|
+
ordered.push(r.url.trim());
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return ordered.length > 0 ? ordered : [DEFAULT_RELAY_URL];
|
|
195
|
+
}
|
|
196
|
+
if (activeRelayState.url?.trim()) {
|
|
197
|
+
return [activeRelayState.url.trim()];
|
|
198
|
+
}
|
|
199
|
+
return [DEFAULT_RELAY_URL];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Update the extension UI status widget with current active relay info
|
|
204
|
+
*/
|
|
205
|
+
export function updateRelayStatusUi(targetUrl?: string): void {
|
|
206
|
+
if (!activeStatusUi?.setStatus) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const currentUrl = targetUrl || activeRelayState.url;
|
|
210
|
+
if (!activeRelayState.enabled || !currentUrl) {
|
|
211
|
+
activeStatusUi.setStatus("freeflow", activeRelayState.enabled ? "relay: direct" : "relay: OFF");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const label = shortRelayLabel(currentUrl);
|
|
215
|
+
const total = activeRelayState.relays.length || 1;
|
|
216
|
+
const pos = Math.max(1, activeRelayState.relays.findIndex((r) => r.url === currentUrl) + 1);
|
|
217
|
+
activeStatusUi.setStatus("freeflow", `relay: ON | ${label} ${pos}/${total}`);
|
|
218
|
+
}
|
package/src/relay.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-resiliency multi-cloud relay client and failover dispatcher for pi-freeflow
|
|
3
|
+
*
|
|
4
|
+
* Implements sticky preference, rolling failover across distributed egress relays,
|
|
5
|
+
* 25-second fast fallback for Vercel 504 timeouts, and automatic direct upstream execution.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { Buffer } from "node:buffer";
|
|
10
|
+
import { isDebugEnabled, log } from "./logger.ts";
|
|
11
|
+
import {
|
|
12
|
+
getActiveRelayState,
|
|
13
|
+
getOrderedRelayUrls,
|
|
14
|
+
saveRelayState,
|
|
15
|
+
setActiveRelayState,
|
|
16
|
+
shortRelayLabel,
|
|
17
|
+
updateRelayStatusUi,
|
|
18
|
+
} from "./relay-state.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Determine if an HTTP status code indicates a temporary relay or upstream error
|
|
22
|
+
* that warrants rolling to the next relay candidate.
|
|
23
|
+
*/
|
|
24
|
+
export function isRetriableStatus(status: number): boolean {
|
|
25
|
+
return (
|
|
26
|
+
status === 429 ||
|
|
27
|
+
status === 408 ||
|
|
28
|
+
status === 500 ||
|
|
29
|
+
status === 502 ||
|
|
30
|
+
status === 503 ||
|
|
31
|
+
status === 504 ||
|
|
32
|
+
(status >= 520 && status <= 530)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fetch a target URL through the active relay pool with rolling failover and direct fallback.
|
|
38
|
+
*
|
|
39
|
+
* @param url Full upstream destination URL (e.g. https://opencode.ai/zen/v1/chat/completions)
|
|
40
|
+
* @param opts Standard fetch RequestInit options
|
|
41
|
+
* @param reqId Optional correlation request ID for end-to-end tracing
|
|
42
|
+
*/
|
|
43
|
+
export async function relayFetch(
|
|
44
|
+
url: string,
|
|
45
|
+
opts: RequestInit = {},
|
|
46
|
+
reqId?: string,
|
|
47
|
+
): Promise<Response> {
|
|
48
|
+
const rid = reqId || randomUUID().slice(0, 8);
|
|
49
|
+
const relayState = getActiveRelayState();
|
|
50
|
+
|
|
51
|
+
if (!relayState.enabled) {
|
|
52
|
+
log("debug", `relayFetch: direct (relay disabled) -> ${url}`, undefined, rid);
|
|
53
|
+
return fetch(url, opts);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const candidates = getOrderedRelayUrls();
|
|
57
|
+
let lastResponse: Response | null = null;
|
|
58
|
+
let lastError: unknown = null;
|
|
59
|
+
const u = new URL(url);
|
|
60
|
+
const relayTarget = `${u.protocol}//${u.host}`;
|
|
61
|
+
const relayPath = `${u.pathname}${u.search}`;
|
|
62
|
+
|
|
63
|
+
const bodySizeKB =
|
|
64
|
+
typeof opts.body === "string"
|
|
65
|
+
? (opts.body.length / 1024).toFixed(1)
|
|
66
|
+
: Buffer.isBuffer(opts.body)
|
|
67
|
+
? (opts.body.length / 1024).toFixed(1)
|
|
68
|
+
: "0";
|
|
69
|
+
|
|
70
|
+
log("info", `request starting (${bodySizeKB}KB payload) -> ${url}`, undefined, rid);
|
|
71
|
+
if (isDebugEnabled()) {
|
|
72
|
+
try {
|
|
73
|
+
const bodyPreview = typeof opts.body === "string" ? opts.body.slice(0, 1200) : "";
|
|
74
|
+
const modelMatch = bodyPreview.match(/"model"\s*:\s*"([^"]+)"/);
|
|
75
|
+
const streamMatch = bodyPreview.match(/"stream"\s*:\s*(true|false)/);
|
|
76
|
+
log("debug", "request detail", {
|
|
77
|
+
model: modelMatch?.[1],
|
|
78
|
+
stream: streamMatch?.[1],
|
|
79
|
+
sizeKB: bodySizeKB,
|
|
80
|
+
relayTarget,
|
|
81
|
+
relayPath,
|
|
82
|
+
candidates: candidates.length,
|
|
83
|
+
}, rid);
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
88
|
+
const targetUrl = candidates[i];
|
|
89
|
+
const attemptStart = Date.now();
|
|
90
|
+
try {
|
|
91
|
+
let targetHost = "opencode.ai";
|
|
92
|
+
try {
|
|
93
|
+
if (targetUrl) targetHost = new URL(targetUrl).host;
|
|
94
|
+
} catch {}
|
|
95
|
+
|
|
96
|
+
const headers = new Headers(opts.headers);
|
|
97
|
+
headers.set("x-relay-target", relayTarget);
|
|
98
|
+
headers.set("x-relay-path", relayPath);
|
|
99
|
+
headers.set("host", targetHost);
|
|
100
|
+
headers.set("x-request-id", rid);
|
|
101
|
+
|
|
102
|
+
const signal = opts.signal || AbortSignal.timeout(300_000);
|
|
103
|
+
const res = await fetch(targetUrl, { ...opts, headers, signal });
|
|
104
|
+
const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
|
|
105
|
+
|
|
106
|
+
// Vercel 504 Gateway Timeout on heavy prompts (>50KB or >25s):
|
|
107
|
+
// Fast fallback directly to upstream instead of cycling through multiple 25s timeouts.
|
|
108
|
+
if (res.status === 504) {
|
|
109
|
+
log(
|
|
110
|
+
"warn",
|
|
111
|
+
`relay ${targetUrl} hit HTTP 504 Gateway Timeout in ${elapsed}s (prompt evaluation exceeded Vercel 25s limit) — fast fallback to direct upstream`,
|
|
112
|
+
{ upstream: url, sizeKB: bodySizeKB },
|
|
113
|
+
rid,
|
|
114
|
+
);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (isRetriableStatus(res.status)) {
|
|
119
|
+
lastResponse = res;
|
|
120
|
+
log(
|
|
121
|
+
"warn",
|
|
122
|
+
`relay ${targetUrl} returned HTTP ${res.status} in ${elapsed}s — rolling to next relay`,
|
|
123
|
+
{ upstream: url, status: res.status },
|
|
124
|
+
rid,
|
|
125
|
+
);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// SUCCESS or non-retriable client error (e.g. 200, 404):
|
|
130
|
+
// If we switched to a different relay because previous failed, update sticky active relay!
|
|
131
|
+
if (relayState.url !== targetUrl) {
|
|
132
|
+
log("info", `active relay auto-switched to ${targetUrl}`, {
|
|
133
|
+
previous: relayState.url,
|
|
134
|
+
}, rid);
|
|
135
|
+
relayState.url = targetUrl;
|
|
136
|
+
saveRelayState(relayState);
|
|
137
|
+
setActiveRelayState(relayState, false);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
log("info", `relay ${targetUrl} succeeded (HTTP ${res.status} in ${elapsed}s)`, undefined, rid);
|
|
141
|
+
if (isDebugEnabled()) {
|
|
142
|
+
log("debug", "relay headers", {
|
|
143
|
+
status: res.status,
|
|
144
|
+
contentType: res.headers.get("content-type"),
|
|
145
|
+
via: res.headers.get("via") || res.headers.get("x-vercel-id") || "direct",
|
|
146
|
+
}, rid);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
updateRelayStatusUi(targetUrl);
|
|
150
|
+
return res;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
|
|
153
|
+
lastError = err;
|
|
154
|
+
log(
|
|
155
|
+
"warn",
|
|
156
|
+
`relay ${targetUrl} fetch error in ${elapsed}s — rolling to next relay`,
|
|
157
|
+
{ upstream: url, error: String(err) },
|
|
158
|
+
rid,
|
|
159
|
+
);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Full fallback: attempt direct fetch to upstream
|
|
165
|
+
const directStart = Date.now();
|
|
166
|
+
try {
|
|
167
|
+
log("warn", "relays bypassed/exhausted — attempting direct fetch to upstream", {
|
|
168
|
+
upstream: url,
|
|
169
|
+
sizeKB: bodySizeKB,
|
|
170
|
+
}, rid);
|
|
171
|
+
|
|
172
|
+
const directHeaders = new Headers(opts.headers);
|
|
173
|
+
directHeaders.delete("x-relay-target");
|
|
174
|
+
directHeaders.delete("x-relay-path");
|
|
175
|
+
directHeaders.set("host", u.host);
|
|
176
|
+
directHeaders.set("x-request-id", rid);
|
|
177
|
+
|
|
178
|
+
const directRes = await fetch(url, { ...opts, headers: directHeaders });
|
|
179
|
+
const directElapsed = ((Date.now() - directStart) / 1000).toFixed(1);
|
|
180
|
+
log("info", `direct fetch returned HTTP ${directRes.status} in ${directElapsed}s`, undefined, rid);
|
|
181
|
+
return directRes;
|
|
182
|
+
} catch (directErr) {
|
|
183
|
+
const directElapsed = ((Date.now() - directStart) / 1000).toFixed(1);
|
|
184
|
+
log("error", `direct fallback also failed in ${directElapsed}s`, {
|
|
185
|
+
upstream: url,
|
|
186
|
+
error: String(directErr),
|
|
187
|
+
}, rid);
|
|
188
|
+
if (lastResponse) {
|
|
189
|
+
return lastResponse;
|
|
190
|
+
}
|
|
191
|
+
throw directErr || lastError;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream SSE streaming pipeline with thinking sniffing and exception isolation.
|
|
3
|
+
*
|
|
4
|
+
* Prevents upstream stream aborts or timeouts from crashing the host process.
|
|
5
|
+
* Sniffs thinking and reasoning chunks for debug trace logging without payload mutation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import type * as http from "node:http";
|
|
10
|
+
import type { Readable } from "node:stream";
|
|
11
|
+
import { isDebugEnabled, log } from "./logger.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pipes an upstream readable stream to a client HTTP response.
|
|
15
|
+
*
|
|
16
|
+
* - Flushes HTTP headers immediately for low Time-to-First-Byte (TTFB).
|
|
17
|
+
* - Detects thinking/reasoning tokens in chunks for diagnostic logs.
|
|
18
|
+
* - Handles upstream errors (returns 502 if headers unsent) and ends response.
|
|
19
|
+
* - Cleans up resources and destroys upstream stream on client disconnect/abort.
|
|
20
|
+
*/
|
|
21
|
+
export function pipeUpstreamStream(
|
|
22
|
+
nodeStream: Readable,
|
|
23
|
+
res: http.ServerResponse,
|
|
24
|
+
req: http.IncomingMessage,
|
|
25
|
+
reqId?: string,
|
|
26
|
+
): void {
|
|
27
|
+
const rid = reqId || randomUUID().slice(0, 8);
|
|
28
|
+
let totalChunks = 0;
|
|
29
|
+
let totalBytes = 0;
|
|
30
|
+
let thinkingChunks = 0;
|
|
31
|
+
let thinkingBytes = 0;
|
|
32
|
+
let firstChunkAt: number | null = null;
|
|
33
|
+
const startAt = Date.now();
|
|
34
|
+
|
|
35
|
+
const sniffThinking = (chunk: Buffer | string): boolean => {
|
|
36
|
+
const s =
|
|
37
|
+
typeof chunk === "string"
|
|
38
|
+
? chunk
|
|
39
|
+
: chunk.toString("utf8", 0, Math.min(chunk.length, 4000));
|
|
40
|
+
return (
|
|
41
|
+
s.includes("reasoning") ||
|
|
42
|
+
s.includes("thinking") ||
|
|
43
|
+
s.includes("<think>") ||
|
|
44
|
+
s.includes("reasoning_content") ||
|
|
45
|
+
s.includes('"type":"thinking"') ||
|
|
46
|
+
s.includes("thinking_delta")
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
if (typeof res.flushHeaders === "function") {
|
|
52
|
+
res.flushHeaders();
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
|
|
56
|
+
nodeStream.on("data", (chunk: Buffer | string) => {
|
|
57
|
+
try {
|
|
58
|
+
if (firstChunkAt === null) {
|
|
59
|
+
firstChunkAt = Date.now();
|
|
60
|
+
const ttfb = firstChunkAt - startAt;
|
|
61
|
+
log("debug", `stream first chunk in ${ttfb}ms`, undefined, rid);
|
|
62
|
+
}
|
|
63
|
+
totalChunks++;
|
|
64
|
+
const chunkSize =
|
|
65
|
+
typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
|
|
66
|
+
totalBytes += chunkSize;
|
|
67
|
+
|
|
68
|
+
if (sniffThinking(chunk)) {
|
|
69
|
+
thinkingChunks++;
|
|
70
|
+
thinkingBytes += chunkSize;
|
|
71
|
+
if (isDebugEnabled() && thinkingChunks <= 3) {
|
|
72
|
+
const preview =
|
|
73
|
+
typeof chunk === "string"
|
|
74
|
+
? chunk.slice(0, 600)
|
|
75
|
+
: chunk.toString("utf8", 0, 600);
|
|
76
|
+
log(
|
|
77
|
+
"debug",
|
|
78
|
+
`thinking chunk #${thinkingChunks}`,
|
|
79
|
+
{ preview: preview.slice(0, 400) },
|
|
80
|
+
rid,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
res.write(chunk);
|
|
86
|
+
const maybeFlush = res as unknown as { flush?: () => void };
|
|
87
|
+
if (typeof maybeFlush.flush === "function") {
|
|
88
|
+
maybeFlush.flush();
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
nodeStream.on("error", (e: unknown) => {
|
|
94
|
+
log(
|
|
95
|
+
"error",
|
|
96
|
+
"upstream stream error",
|
|
97
|
+
{ error: String(e), totalChunks, thinkingChunks },
|
|
98
|
+
rid,
|
|
99
|
+
);
|
|
100
|
+
try {
|
|
101
|
+
if (!res.headersSent) {
|
|
102
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
103
|
+
}
|
|
104
|
+
if (!res.writableEnded) {
|
|
105
|
+
res.end();
|
|
106
|
+
}
|
|
107
|
+
} catch {}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
nodeStream.on("end", () => {
|
|
111
|
+
const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
|
|
112
|
+
if (thinkingChunks > 0) {
|
|
113
|
+
log(
|
|
114
|
+
"info",
|
|
115
|
+
`stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes / 1024).toFixed(1)}KB), thinking: ${thinkingChunks} chunks (${(thinkingBytes / 1024).toFixed(1)}KB)`,
|
|
116
|
+
undefined,
|
|
117
|
+
rid,
|
|
118
|
+
);
|
|
119
|
+
} else if (isDebugEnabled()) {
|
|
120
|
+
log(
|
|
121
|
+
"debug",
|
|
122
|
+
`stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes / 1024).toFixed(1)}KB), no thinking detected`,
|
|
123
|
+
undefined,
|
|
124
|
+
rid,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
if (!res.writableEnded) res.end();
|
|
129
|
+
} catch {}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
nodeStream.on("close", () => {
|
|
133
|
+
try {
|
|
134
|
+
if (!res.writableEnded) res.end();
|
|
135
|
+
} catch {}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
req.on("aborted", () => {
|
|
139
|
+
log("warn", "client aborted — destroying upstream", { totalChunks }, rid);
|
|
140
|
+
if (!nodeStream.destroyed) nodeStream.destroy();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
req.on("close", () => {
|
|
144
|
+
if (!nodeStream.destroyed) nodeStream.destroy();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
res.on("close", () => {
|
|
148
|
+
if (!nodeStream.destroyed) nodeStream.destroy();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
res.on("error", () => {
|
|
152
|
+
if (!nodeStream.destroyed) nodeStream.destroy();
|
|
153
|
+
});
|
|
154
|
+
}
|