@manny-est/node-red-flowpilot 0.5.1 → 0.6.0-beta.1
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/CHANGELOG.md +58 -0
- package/README.md +21 -7
- package/USER-GUIDE.md +27 -14
- package/flowpilot-core.css +159 -5
- package/flowpilot.js +1353 -172
- package/lib/agent-contract.js +45 -0
- package/lib/build-core-script.js +1 -0
- package/lib/build-system-prompt.js +26 -4
- package/lib/chat-data.js +106 -0
- package/lib/core/apply-review.js +268 -64
- package/lib/core/graph-truth.js +63 -0
- package/lib/core/history.js +10 -1
- package/lib/core/init.js +148 -5
- package/lib/core/main.js +755 -21
- package/lib/core/modes.js +1523 -67
- package/lib/core/selection-context.js +31 -1
- package/lib/default-system-prompt.js +17 -12
- package/lib/document-system-prompt.js +22 -32
- package/lib/envelope.js +13 -7
- package/lib/generation-system-prompt.js +29 -44
- package/lib/modify-system-prompt.js +152 -72
- package/lib/persona-prompt.js +81 -54
- package/lib/prompt-fragments.js +56 -0
- package/lib/provider-anthropic.js +388 -0
- package/lib/provider-openai-compatible.js +49 -12
- package/lib/provider-shape-check.js +34 -0
- package/lib/storage.js +160 -12
- package/lib/validator.js +238 -0
- package/package.json +1 -1
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
const https = require("https");
|
|
2
|
+
const http = require("http");
|
|
3
|
+
|
|
4
|
+
const ANTHROPIC_API_BASE = "https://api.anthropic.com";
|
|
5
|
+
const ANTHROPIC_VERSION = "2023-06-01";
|
|
6
|
+
const DEFAULT_MAX_TOKENS = 8192;
|
|
7
|
+
|
|
8
|
+
// ---- HTTP helpers ----
|
|
9
|
+
|
|
10
|
+
function postJson(urlString, headers, body, timeoutMs) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
let url;
|
|
13
|
+
try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
|
|
14
|
+
|
|
15
|
+
const payload = JSON.stringify(body);
|
|
16
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
17
|
+
const req = transport.request({
|
|
18
|
+
method: "POST",
|
|
19
|
+
hostname: url.hostname,
|
|
20
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
21
|
+
path: url.pathname + url.search,
|
|
22
|
+
headers: Object.assign({ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, headers || {})
|
|
23
|
+
}, (res) => {
|
|
24
|
+
let data = "";
|
|
25
|
+
res.setEncoding("utf8");
|
|
26
|
+
res.on("data", chunk => { data += chunk; });
|
|
27
|
+
res.on("end", () => {
|
|
28
|
+
let parsed = null;
|
|
29
|
+
try { parsed = data ? JSON.parse(data) : null; } catch (err) {
|
|
30
|
+
// Never echo the raw upstream body — see the matching comment in
|
|
31
|
+
// provider-openai-compatible.js (ADR-007, the SSRF mitigation).
|
|
32
|
+
reject(new Error("Provider returned a non-JSON response (status " + res.statusCode + ")."));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
36
|
+
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : ("status " + res.statusCode);
|
|
37
|
+
reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
resolve(parsed);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
req.on("error", reject);
|
|
44
|
+
req.setTimeout(timeoutMs || 180000, () => {
|
|
45
|
+
req.destroy(new Error("Provider request timed out after " + (timeoutMs || 180000) + "ms — increase the request timeout in Settings → Behavior for slower hardware."));
|
|
46
|
+
});
|
|
47
|
+
req.write(payload);
|
|
48
|
+
req.end();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getJson(urlString, headers, timeoutMs) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let url;
|
|
55
|
+
try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
|
|
56
|
+
|
|
57
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
58
|
+
const req = transport.request({
|
|
59
|
+
method: "GET",
|
|
60
|
+
hostname: url.hostname,
|
|
61
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
62
|
+
path: url.pathname + url.search,
|
|
63
|
+
headers: headers || {}
|
|
64
|
+
}, (res) => {
|
|
65
|
+
let data = "";
|
|
66
|
+
res.setEncoding("utf8");
|
|
67
|
+
res.on("data", chunk => { data += chunk; });
|
|
68
|
+
res.on("end", () => {
|
|
69
|
+
let parsed = null;
|
|
70
|
+
try { parsed = data ? JSON.parse(data) : null; } catch (err) {
|
|
71
|
+
reject(new Error("Provider returned a non-JSON response (status " + res.statusCode + ")."));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
75
|
+
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : ("status " + res.statusCode);
|
|
76
|
+
reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
resolve(parsed);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
req.on("error", reject);
|
|
83
|
+
req.setTimeout(timeoutMs || 30000, () => {
|
|
84
|
+
req.destroy(new Error("Timeout"));
|
|
85
|
+
});
|
|
86
|
+
req.end();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDelta) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
let url;
|
|
93
|
+
try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
|
|
94
|
+
|
|
95
|
+
const payload = JSON.stringify(body);
|
|
96
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
97
|
+
const startedAt = Date.now();
|
|
98
|
+
let firstTokenAt = null;
|
|
99
|
+
let usage = null;
|
|
100
|
+
let full = "";
|
|
101
|
+
|
|
102
|
+
const req = transport.request({
|
|
103
|
+
method: "POST",
|
|
104
|
+
hostname: url.hostname,
|
|
105
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
106
|
+
path: url.pathname + url.search,
|
|
107
|
+
headers: Object.assign({ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, headers || {})
|
|
108
|
+
}, (res) => {
|
|
109
|
+
res.setEncoding("utf8");
|
|
110
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
111
|
+
res.on("data", () => {});
|
|
112
|
+
res.on("end", () => { reject(new Error("Provider request failed (status " + res.statusCode + ").")); });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let sseBuf = "";
|
|
117
|
+
res.on("data", (chunk) => {
|
|
118
|
+
sseBuf += chunk;
|
|
119
|
+
const lines = sseBuf.split("\n");
|
|
120
|
+
sseBuf = lines.pop();
|
|
121
|
+
lines.forEach((line) => {
|
|
122
|
+
line = line.trim();
|
|
123
|
+
if (!line.startsWith("data:")) { return; }
|
|
124
|
+
const dataStr = line.slice(5).trim();
|
|
125
|
+
if (!dataStr) { return; }
|
|
126
|
+
let evt;
|
|
127
|
+
try { evt = JSON.parse(dataStr); } catch (e) { return; }
|
|
128
|
+
|
|
129
|
+
// Anthropic SSE event types used here:
|
|
130
|
+
// message_start: carries input_tokens
|
|
131
|
+
// content_block_delta: text_delta or thinking_delta
|
|
132
|
+
// message_delta: carries output_tokens
|
|
133
|
+
if (evt.type === "message_start" && evt.message && evt.message.usage) {
|
|
134
|
+
usage = { prompt_tokens: evt.message.usage.input_tokens || 0, completion_tokens: 0 };
|
|
135
|
+
} else if (evt.type === "content_block_delta" && evt.delta) {
|
|
136
|
+
if (evt.delta.type === "text_delta" && evt.delta.text) {
|
|
137
|
+
if (firstTokenAt === null) { firstTokenAt = Date.now(); }
|
|
138
|
+
full += evt.delta.text;
|
|
139
|
+
onDelta(evt.delta.text);
|
|
140
|
+
} else if (evt.delta.type === "thinking_delta" && evt.delta.thinking && onReasoningDelta) {
|
|
141
|
+
onReasoningDelta(evt.delta.thinking);
|
|
142
|
+
}
|
|
143
|
+
} else if (evt.type === "message_delta" && evt.usage) {
|
|
144
|
+
if (usage) { usage.completion_tokens = evt.usage.output_tokens || 0; }
|
|
145
|
+
else { usage = { prompt_tokens: 0, completion_tokens: evt.usage.output_tokens || 0 }; }
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
res.on("end", () => {
|
|
150
|
+
resolve({ content: full, ttftMs: firstTokenAt !== null ? firstTokenAt - startedAt : null, totalMs: Date.now() - startedAt, usage: usage });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
req.on("error", reject);
|
|
155
|
+
req.setTimeout(timeoutMs || 180000, () => {
|
|
156
|
+
req.destroy(new Error("Provider request timed out after " + (timeoutMs || 180000) + "ms — increase the request timeout in Settings → Behavior for slower hardware."));
|
|
157
|
+
});
|
|
158
|
+
req.write(payload);
|
|
159
|
+
req.end();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- Message format conversion ----
|
|
164
|
+
|
|
165
|
+
// OpenAI {type:"function", function:{name,description,parameters}} →
|
|
166
|
+
// Anthropic {name, description, input_schema}
|
|
167
|
+
function toAnthropicTool(tool) {
|
|
168
|
+
const fn = tool && tool.function;
|
|
169
|
+
if (!fn) { return null; }
|
|
170
|
+
return {
|
|
171
|
+
name: fn.name,
|
|
172
|
+
description: fn.description || "",
|
|
173
|
+
input_schema: fn.parameters || { type: "object", properties: {} }
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Anthropic {type:"tool_use", id, name, input} →
|
|
178
|
+
// OpenAI {id, type:"function", function:{name, arguments:string}}
|
|
179
|
+
function toOpenAiToolCall(block) {
|
|
180
|
+
return {
|
|
181
|
+
id: block.id,
|
|
182
|
+
type: "function",
|
|
183
|
+
function: {
|
|
184
|
+
name: block.name,
|
|
185
|
+
arguments: JSON.stringify(block.input || {})
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Convert an OpenAI-shaped messages array to Anthropic format.
|
|
191
|
+
// Returns { system: string, messages: [...] }
|
|
192
|
+
//
|
|
193
|
+
// - role:"system" messages are extracted and concatenated into top-level system param
|
|
194
|
+
// - role:"assistant" + tool_calls converted to Anthropic tool_use content blocks
|
|
195
|
+
// - role:"tool" results converted and grouped into user messages with tool_result blocks
|
|
196
|
+
// - role:"user" and plain role:"assistant" pass through unchanged
|
|
197
|
+
function convertMessages(messages) {
|
|
198
|
+
const systemParts = [];
|
|
199
|
+
const converted = [];
|
|
200
|
+
|
|
201
|
+
for (let i = 0; i < messages.length; i++) {
|
|
202
|
+
const msg = messages[i];
|
|
203
|
+
|
|
204
|
+
if (msg.role === "system") {
|
|
205
|
+
if (typeof msg.content === "string" && msg.content) {
|
|
206
|
+
systemParts.push(msg.content);
|
|
207
|
+
}
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Group consecutive tool result messages into one user message
|
|
212
|
+
if (msg.role === "tool") {
|
|
213
|
+
const toolResults = [];
|
|
214
|
+
while (i < messages.length && messages[i].role === "tool") {
|
|
215
|
+
toolResults.push({
|
|
216
|
+
type: "tool_result",
|
|
217
|
+
tool_use_id: messages[i].tool_call_id,
|
|
218
|
+
content: messages[i].content || ""
|
|
219
|
+
});
|
|
220
|
+
i++;
|
|
221
|
+
}
|
|
222
|
+
i--; // outer loop will increment
|
|
223
|
+
converted.push({ role: "user", content: toolResults });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Assistant with tool_calls → Anthropic tool_use content blocks
|
|
228
|
+
if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length) {
|
|
229
|
+
const content = [];
|
|
230
|
+
if (msg.content) { content.push({ type: "text", text: msg.content }); }
|
|
231
|
+
msg.tool_calls.forEach(function (tc) {
|
|
232
|
+
let input = {};
|
|
233
|
+
try { input = JSON.parse(tc.function.arguments || "{}"); } catch (e) {}
|
|
234
|
+
content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: input });
|
|
235
|
+
});
|
|
236
|
+
converted.push({ role: "assistant", content: content });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
converted.push({ role: msg.role, content: msg.content });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { system: systemParts.join("\n\n"), messages: converted };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function anthropicHeaders(settings) {
|
|
247
|
+
return {
|
|
248
|
+
"x-api-key": settings.apiKey || "",
|
|
249
|
+
"anthropic-version": ANTHROPIC_VERSION
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function resolveBaseUrl(settings) {
|
|
254
|
+
return String(settings.baseUrl || ANTHROPIC_API_BASE).replace(/\/+$/, "") || ANTHROPIC_API_BASE;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---- chat ----
|
|
258
|
+
|
|
259
|
+
async function chat(settings, messages, options) {
|
|
260
|
+
if (!settings.model) { throw new Error("Model is required."); }
|
|
261
|
+
const baseUrl = resolveBaseUrl(settings);
|
|
262
|
+
const { system, messages: anthropicMessages } = convertMessages(messages);
|
|
263
|
+
const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
|
|
264
|
+
|
|
265
|
+
const body = {
|
|
266
|
+
model: settings.model,
|
|
267
|
+
messages: anthropicMessages,
|
|
268
|
+
max_tokens: DEFAULT_MAX_TOKENS,
|
|
269
|
+
temperature: temperature,
|
|
270
|
+
stream: false
|
|
271
|
+
};
|
|
272
|
+
if (system) { body.system = system; }
|
|
273
|
+
if (options && Array.isArray(options.tools) && options.tools.length) {
|
|
274
|
+
body.tools = options.tools.map(toAnthropicTool).filter(Boolean);
|
|
275
|
+
body.tool_choice = {
|
|
276
|
+
type: options.toolChoice === "required" ? "any" : "auto"
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const startedAt = Date.now();
|
|
281
|
+
const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000);
|
|
282
|
+
const totalMs = Date.now() - startedAt;
|
|
283
|
+
|
|
284
|
+
const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
|
|
285
|
+
const textContent = contentArray.filter(function (b) { return b.type === "text"; }).map(function (b) { return b.text; }).join("");
|
|
286
|
+
const toolUseBlocks = contentArray.filter(function (b) { return b.type === "tool_use"; });
|
|
287
|
+
const toolCalls = toolUseBlocks.length ? toolUseBlocks.map(toOpenAiToolCall) : null;
|
|
288
|
+
const usage = (response && response.usage)
|
|
289
|
+
? { prompt_tokens: response.usage.input_tokens, completion_tokens: response.usage.output_tokens }
|
|
290
|
+
: null;
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
raw: response,
|
|
294
|
+
content: textContent || (toolCalls ? "" : "[No assistant message returned by provider]"),
|
|
295
|
+
toolCalls: toolCalls,
|
|
296
|
+
timing: { totalMs: totalMs },
|
|
297
|
+
usage: usage
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---- chatStream ----
|
|
302
|
+
|
|
303
|
+
async function chatStream(settings, messages, onDelta, onReasoningDelta) {
|
|
304
|
+
if (!settings.model) { throw new Error("Model is required."); }
|
|
305
|
+
const baseUrl = resolveBaseUrl(settings);
|
|
306
|
+
const { system, messages: anthropicMessages } = convertMessages(messages);
|
|
307
|
+
const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
|
|
308
|
+
|
|
309
|
+
const body = {
|
|
310
|
+
model: settings.model,
|
|
311
|
+
messages: anthropicMessages,
|
|
312
|
+
max_tokens: DEFAULT_MAX_TOKENS,
|
|
313
|
+
temperature: temperature,
|
|
314
|
+
stream: true
|
|
315
|
+
};
|
|
316
|
+
if (system) { body.system = system; }
|
|
317
|
+
|
|
318
|
+
const result = await postStream(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
content: result.content || "",
|
|
322
|
+
timing: { ttftMs: result.ttftMs, totalMs: result.totalMs },
|
|
323
|
+
usage: result.usage
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---- listModels ----
|
|
328
|
+
// Tries GET /v1/models; falls back to a hardcoded list if that endpoint
|
|
329
|
+
// is unavailable (non-standard proxy) or returns an error.
|
|
330
|
+
async function listModels(settings) {
|
|
331
|
+
const baseUrl = resolveBaseUrl(settings);
|
|
332
|
+
try {
|
|
333
|
+
const response = await getJson(baseUrl + "/v1/models", anthropicHeaders(settings), settings.requestTimeoutMs || 30000);
|
|
334
|
+
const data = response && Array.isArray(response.data) ? response.data : [];
|
|
335
|
+
const models = data.map(function (m) { return m && m.id; }).filter(function (id) { return typeof id === "string" && id; });
|
|
336
|
+
if (models.length) { return { models: models }; }
|
|
337
|
+
throw new Error("Empty model list from provider");
|
|
338
|
+
} catch (err) {
|
|
339
|
+
return {
|
|
340
|
+
models: [
|
|
341
|
+
"claude-opus-4-8",
|
|
342
|
+
"claude-sonnet-5",
|
|
343
|
+
"claude-haiku-4-5-20251001"
|
|
344
|
+
],
|
|
345
|
+
error: err.message
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- probeTools ----
|
|
351
|
+
async function probeTools(settings) {
|
|
352
|
+
if (!settings.model) { throw new Error("Model is required."); }
|
|
353
|
+
const baseUrl = resolveBaseUrl(settings);
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), {
|
|
357
|
+
model: settings.model,
|
|
358
|
+
messages: [{ role: "user", content: "Call the \"ping\" tool now with no arguments." }],
|
|
359
|
+
system: "You are being tested for tool/function-calling support.",
|
|
360
|
+
tools: [{
|
|
361
|
+
name: "ping",
|
|
362
|
+
description: "Respond to a connectivity probe. Takes no arguments.",
|
|
363
|
+
input_schema: { type: "object", properties: {}, additionalProperties: false }
|
|
364
|
+
}],
|
|
365
|
+
tool_choice: { type: "auto" },
|
|
366
|
+
max_tokens: 128,
|
|
367
|
+
temperature: 0,
|
|
368
|
+
stream: false
|
|
369
|
+
}, settings.requestTimeoutMs || 30000);
|
|
370
|
+
|
|
371
|
+
const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
|
|
372
|
+
const hasToolUse = contentArray.some(function (b) { return b.type === "tool_use" && b.name === "ping"; });
|
|
373
|
+
return { supportsTools: hasToolUse };
|
|
374
|
+
} catch (err) {
|
|
375
|
+
return { supportsTools: false, error: err.message };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- detectReasoning ----
|
|
380
|
+
// Extended thinking responses include content blocks with type:"thinking".
|
|
381
|
+
// Standard non-thinking responses never include them.
|
|
382
|
+
function detectReasoning(rawResponse) {
|
|
383
|
+
const contentArray = (rawResponse && Array.isArray(rawResponse.content)) ? rawResponse.content : [];
|
|
384
|
+
const isReasoningModel = contentArray.some(function (b) { return b && b.type === "thinking" && b.thinking; });
|
|
385
|
+
return { isReasoningModel: isReasoningModel };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
module.exports = { chat, chatStream, probeTools, listModels, detectReasoning };
|
|
@@ -33,12 +33,18 @@ function postJson(urlString, headers, body, timeoutMs) {
|
|
|
33
33
|
try {
|
|
34
34
|
parsed = data ? JSON.parse(data) : null;
|
|
35
35
|
} catch (err) {
|
|
36
|
-
|
|
36
|
+
// Never echo the raw upstream body — a security boundary, not just
|
|
37
|
+
// tidiness. This request may be the provider-confirmation check
|
|
38
|
+
// hitting a baseUrl for the first time (SSRF mitigation, ADR-007);
|
|
39
|
+
// an attacker-controlled target (internal service, cloud metadata)
|
|
40
|
+
// must not be able to get its response body reflected back to the
|
|
41
|
+
// caller through a FlowPilot error message.
|
|
42
|
+
reject(new Error(`Provider returned a non-JSON response (status ${res.statusCode}).`));
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
41
|
-
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) :
|
|
47
|
+
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : `status ${res.statusCode}`;
|
|
42
48
|
reject(new Error(`Provider request failed (${res.statusCode}): ${msg}`));
|
|
43
49
|
return;
|
|
44
50
|
}
|
|
@@ -86,12 +92,13 @@ function getJson(urlString, headers, timeoutMs) {
|
|
|
86
92
|
try {
|
|
87
93
|
parsed = data ? JSON.parse(data) : null;
|
|
88
94
|
} catch (err) {
|
|
89
|
-
|
|
95
|
+
// See postJson above — never echo the raw upstream body.
|
|
96
|
+
reject(new Error(`Provider returned a non-JSON response (status ${res.statusCode}).`));
|
|
90
97
|
return;
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
94
|
-
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) :
|
|
101
|
+
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : `status ${res.statusCode}`;
|
|
95
102
|
reject(new Error(`Provider request failed (${res.statusCode}): ${msg}`));
|
|
96
103
|
return;
|
|
97
104
|
}
|
|
@@ -169,6 +176,12 @@ async function chat(settings, messages, options) {
|
|
|
169
176
|
body.tools = options.tools;
|
|
170
177
|
body.tool_choice = options.toolChoice || "auto";
|
|
171
178
|
}
|
|
179
|
+
if (options && options.responseFormat) {
|
|
180
|
+
body.response_format = options.responseFormat;
|
|
181
|
+
}
|
|
182
|
+
if (options && Number.isInteger(options.maxTokens) && options.maxTokens > 0) {
|
|
183
|
+
body.max_tokens = options.maxTokens;
|
|
184
|
+
}
|
|
172
185
|
|
|
173
186
|
const startedAt = Date.now();
|
|
174
187
|
const response = await postJson(`${baseUrl}/v1/chat/completions`, headers, body, settings.requestTimeoutMs || 180000);
|
|
@@ -184,6 +197,8 @@ async function chat(settings, messages, options) {
|
|
|
184
197
|
raw: response,
|
|
185
198
|
content: content || (toolCalls ? "" : "[No assistant message returned by provider]"),
|
|
186
199
|
toolCalls: toolCalls,
|
|
200
|
+
finishReason: (response && response.choices && response.choices[0] &&
|
|
201
|
+
response.choices[0].finish_reason) || null,
|
|
187
202
|
timing: { totalMs },
|
|
188
203
|
usage: (response && response.usage) || null
|
|
189
204
|
};
|
|
@@ -279,20 +294,22 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
|
|
|
279
294
|
const startedAt = Date.now();
|
|
280
295
|
let firstTokenAt = null;
|
|
281
296
|
let usage = null;
|
|
297
|
+
let finishReason = null;
|
|
282
298
|
|
|
283
299
|
const req = transport.request(options, (res) => {
|
|
284
300
|
res.setEncoding("utf8");
|
|
285
301
|
|
|
286
302
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
287
|
-
|
|
288
|
-
res.on("data",
|
|
303
|
+
// Drain the body but never reflect it — see postJson's comment above.
|
|
304
|
+
res.on("data", () => {});
|
|
289
305
|
res.on("end", () => {
|
|
290
|
-
reject(new Error(`Provider request failed (${res.statusCode})
|
|
306
|
+
reject(new Error(`Provider request failed (status ${res.statusCode}).`));
|
|
291
307
|
});
|
|
292
308
|
return;
|
|
293
309
|
}
|
|
294
310
|
|
|
295
311
|
let sseBuf = "";
|
|
312
|
+
let sawValidSseData = false;
|
|
296
313
|
let full = "";
|
|
297
314
|
// When onReasoningDelta is provided, intercept <think>...</think> from
|
|
298
315
|
// delta.content in addition to the dedicated delta.reasoning_content field
|
|
@@ -322,7 +339,12 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
|
|
|
322
339
|
return; // ignore malformed/partial SSE chunk
|
|
323
340
|
}
|
|
324
341
|
|
|
342
|
+
sawValidSseData = true;
|
|
343
|
+
|
|
325
344
|
if (evt && evt.usage) { usage = evt.usage; }
|
|
345
|
+
if (evt && evt.choices && evt.choices[0] && evt.choices[0].finish_reason) {
|
|
346
|
+
finishReason = evt.choices[0].finish_reason;
|
|
347
|
+
}
|
|
326
348
|
|
|
327
349
|
const deltaObj = evt && evt.choices && evt.choices[0] && evt.choices[0].delta;
|
|
328
350
|
if (deltaObj) {
|
|
@@ -347,12 +369,17 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
|
|
|
347
369
|
});
|
|
348
370
|
res.on("end", () => {
|
|
349
371
|
if (thinkSplitter) { thinkSplitter.finish(); }
|
|
372
|
+
if (!sawValidSseData) {
|
|
373
|
+
reject(new Error(`Provider returned a non-SSE response (status ${res.statusCode}).`));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
350
376
|
const endedAt = Date.now();
|
|
351
377
|
resolve({
|
|
352
378
|
content: full,
|
|
353
379
|
ttftMs: firstTokenAt !== null ? firstTokenAt - startedAt : null,
|
|
354
380
|
totalMs: endedAt - startedAt,
|
|
355
|
-
usage: usage
|
|
381
|
+
usage: usage,
|
|
382
|
+
finishReason: finishReason
|
|
356
383
|
});
|
|
357
384
|
});
|
|
358
385
|
});
|
|
@@ -368,7 +395,7 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
|
|
|
368
395
|
});
|
|
369
396
|
}
|
|
370
397
|
|
|
371
|
-
async function chatStream(settings, messages, onDelta, onReasoningDelta) {
|
|
398
|
+
async function chatStream(settings, messages, onDelta, onReasoningDelta, options) {
|
|
372
399
|
const baseUrl = String(settings.baseUrl || "").replace(/\/+$/, "");
|
|
373
400
|
if (!baseUrl) throw new Error("Provider base URL is required.");
|
|
374
401
|
if (!settings.model) throw new Error("Model is required.");
|
|
@@ -384,18 +411,28 @@ async function chatStream(settings, messages, onDelta, onReasoningDelta) {
|
|
|
384
411
|
// final SSE chunk carrying token usage (no delta) before [DONE]. Providers
|
|
385
412
|
// that don't support it just ignore the option; postStream treats a
|
|
386
413
|
// missing usage field as null either way.
|
|
387
|
-
const
|
|
414
|
+
const body = {
|
|
388
415
|
model: settings.model,
|
|
389
416
|
messages,
|
|
390
417
|
temperature,
|
|
391
418
|
stream: true,
|
|
392
419
|
stream_options: { include_usage: true }
|
|
393
|
-
}
|
|
420
|
+
};
|
|
421
|
+
if (options && options.responseFormat) {
|
|
422
|
+
body.response_format = options.responseFormat;
|
|
423
|
+
}
|
|
424
|
+
if (options && Number.isInteger(options.maxTokens) && options.maxTokens > 0) {
|
|
425
|
+
body.max_tokens = options.maxTokens;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const result = await postStream(`${baseUrl}/v1/chat/completions`, headers,
|
|
429
|
+
body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
|
|
394
430
|
|
|
395
431
|
return {
|
|
396
432
|
content: result.content || "",
|
|
397
433
|
timing: { ttftMs: result.ttftMs, totalMs: result.totalMs },
|
|
398
|
-
usage: result.usage
|
|
434
|
+
usage: result.usage,
|
|
435
|
+
finishReason: result.finishReason
|
|
399
436
|
};
|
|
400
437
|
}
|
|
401
438
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------
|
|
4
|
+
// The provider-confirmation gate's own pass/fail criterion (ADR-007, the
|
|
5
|
+
// SSRF mitigation). Deliberately NOT "HTTP 200 with a JSON body" — an
|
|
6
|
+
// internal admin panel or a cloud metadata endpoint can trivially return
|
|
7
|
+
// that. Requires an actually provider-shaped response: a well-formed
|
|
8
|
+
// OpenAI-compatible chat-completion object (choices[].message) or Anthropic
|
|
9
|
+
// message (content[]), or a valid OpenAI-style /v1/models list (data[] of
|
|
10
|
+
// {id}). Anything else — including a bare 200, an HTML error page, or JSON
|
|
11
|
+
// that merely happens to parse but isn't shaped like either — fails the
|
|
12
|
+
// check, and the provider stays unconfirmed.
|
|
13
|
+
// ---------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function isChatShaped(providerType, raw) {
|
|
16
|
+
if (providerType === "anthropic") {
|
|
17
|
+
return Array.isArray(raw.content);
|
|
18
|
+
}
|
|
19
|
+
return Array.isArray(raw.choices) && raw.choices.length > 0 &&
|
|
20
|
+
raw.choices[0] && typeof raw.choices[0] === "object" &&
|
|
21
|
+
raw.choices[0].message && typeof raw.choices[0].message === "object";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isModelsListShaped(raw) {
|
|
25
|
+
return Array.isArray(raw.data) && raw.data.length > 0 &&
|
|
26
|
+
raw.data.every(function (m) { return m && typeof m.id === "string" && m.id; });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isProviderShapedResponse(providerType, raw) {
|
|
30
|
+
if (!raw || typeof raw !== "object") { return false; }
|
|
31
|
+
return isChatShaped(providerType, raw) || isModelsListShaped(raw);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { isProviderShapedResponse };
|