@manny-est/node-red-flowpilot 0.5.0 → 0.5.2

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.
@@ -0,0 +1,385 @@
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
+ reject(new Error("Provider returned non-JSON response (" + res.statusCode + "): " + data.slice(0, 500)));
31
+ return;
32
+ }
33
+ if (res.statusCode < 200 || res.statusCode >= 300) {
34
+ const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : data;
35
+ reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
36
+ return;
37
+ }
38
+ resolve(parsed);
39
+ });
40
+ });
41
+ req.on("error", reject);
42
+ req.setTimeout(timeoutMs || 180000, () => {
43
+ req.destroy(new Error("Provider request timed out after " + (timeoutMs || 180000) + "ms — increase the request timeout in Settings → Behavior for slower hardware."));
44
+ });
45
+ req.write(payload);
46
+ req.end();
47
+ });
48
+ }
49
+
50
+ function getJson(urlString, headers, timeoutMs) {
51
+ return new Promise((resolve, reject) => {
52
+ let url;
53
+ try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
54
+
55
+ const transport = url.protocol === "https:" ? https : http;
56
+ const req = transport.request({
57
+ method: "GET",
58
+ hostname: url.hostname,
59
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
60
+ path: url.pathname + url.search,
61
+ headers: headers || {}
62
+ }, (res) => {
63
+ let data = "";
64
+ res.setEncoding("utf8");
65
+ res.on("data", chunk => { data += chunk; });
66
+ res.on("end", () => {
67
+ let parsed = null;
68
+ try { parsed = data ? JSON.parse(data) : null; } catch (err) {
69
+ reject(new Error("Provider returned non-JSON response (" + res.statusCode + "): " + data.slice(0, 500)));
70
+ return;
71
+ }
72
+ if (res.statusCode < 200 || res.statusCode >= 300) {
73
+ const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : data;
74
+ reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
75
+ return;
76
+ }
77
+ resolve(parsed);
78
+ });
79
+ });
80
+ req.on("error", reject);
81
+ req.setTimeout(timeoutMs || 30000, () => {
82
+ req.destroy(new Error("Timeout"));
83
+ });
84
+ req.end();
85
+ });
86
+ }
87
+
88
+ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDelta) {
89
+ return new Promise((resolve, reject) => {
90
+ let url;
91
+ try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
92
+
93
+ const payload = JSON.stringify(body);
94
+ const transport = url.protocol === "https:" ? https : http;
95
+ const startedAt = Date.now();
96
+ let firstTokenAt = null;
97
+ let usage = null;
98
+ let full = "";
99
+
100
+ const req = transport.request({
101
+ method: "POST",
102
+ hostname: url.hostname,
103
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
104
+ path: url.pathname + url.search,
105
+ headers: Object.assign({ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, headers || {})
106
+ }, (res) => {
107
+ res.setEncoding("utf8");
108
+ if (res.statusCode < 200 || res.statusCode >= 300) {
109
+ let errData = "";
110
+ res.on("data", chunk => { errData += chunk; });
111
+ res.on("end", () => { reject(new Error("Provider request failed (" + res.statusCode + "): " + errData.slice(0, 500))); });
112
+ return;
113
+ }
114
+
115
+ let sseBuf = "";
116
+ res.on("data", (chunk) => {
117
+ sseBuf += chunk;
118
+ const lines = sseBuf.split("\n");
119
+ sseBuf = lines.pop();
120
+ lines.forEach((line) => {
121
+ line = line.trim();
122
+ if (!line.startsWith("data:")) { return; }
123
+ const dataStr = line.slice(5).trim();
124
+ if (!dataStr) { return; }
125
+ let evt;
126
+ try { evt = JSON.parse(dataStr); } catch (e) { return; }
127
+
128
+ // Anthropic SSE event types used here:
129
+ // message_start: carries input_tokens
130
+ // content_block_delta: text_delta or thinking_delta
131
+ // message_delta: carries output_tokens
132
+ if (evt.type === "message_start" && evt.message && evt.message.usage) {
133
+ usage = { prompt_tokens: evt.message.usage.input_tokens || 0, completion_tokens: 0 };
134
+ } else if (evt.type === "content_block_delta" && evt.delta) {
135
+ if (evt.delta.type === "text_delta" && evt.delta.text) {
136
+ if (firstTokenAt === null) { firstTokenAt = Date.now(); }
137
+ full += evt.delta.text;
138
+ onDelta(evt.delta.text);
139
+ } else if (evt.delta.type === "thinking_delta" && evt.delta.thinking && onReasoningDelta) {
140
+ onReasoningDelta(evt.delta.thinking);
141
+ }
142
+ } else if (evt.type === "message_delta" && evt.usage) {
143
+ if (usage) { usage.completion_tokens = evt.usage.output_tokens || 0; }
144
+ else { usage = { prompt_tokens: 0, completion_tokens: evt.usage.output_tokens || 0 }; }
145
+ }
146
+ });
147
+ });
148
+ res.on("end", () => {
149
+ resolve({ content: full, ttftMs: firstTokenAt !== null ? firstTokenAt - startedAt : null, totalMs: Date.now() - startedAt, usage: usage });
150
+ });
151
+ });
152
+
153
+ req.on("error", reject);
154
+ req.setTimeout(timeoutMs || 180000, () => {
155
+ req.destroy(new Error("Provider request timed out after " + (timeoutMs || 180000) + "ms — increase the request timeout in Settings → Behavior for slower hardware."));
156
+ });
157
+ req.write(payload);
158
+ req.end();
159
+ });
160
+ }
161
+
162
+ // ---- Message format conversion ----
163
+
164
+ // OpenAI {type:"function", function:{name,description,parameters}} →
165
+ // Anthropic {name, description, input_schema}
166
+ function toAnthropicTool(tool) {
167
+ const fn = tool && tool.function;
168
+ if (!fn) { return null; }
169
+ return {
170
+ name: fn.name,
171
+ description: fn.description || "",
172
+ input_schema: fn.parameters || { type: "object", properties: {} }
173
+ };
174
+ }
175
+
176
+ // Anthropic {type:"tool_use", id, name, input} →
177
+ // OpenAI {id, type:"function", function:{name, arguments:string}}
178
+ function toOpenAiToolCall(block) {
179
+ return {
180
+ id: block.id,
181
+ type: "function",
182
+ function: {
183
+ name: block.name,
184
+ arguments: JSON.stringify(block.input || {})
185
+ }
186
+ };
187
+ }
188
+
189
+ // Convert an OpenAI-shaped messages array to Anthropic format.
190
+ // Returns { system: string, messages: [...] }
191
+ //
192
+ // - role:"system" messages are extracted and concatenated into top-level system param
193
+ // - role:"assistant" + tool_calls converted to Anthropic tool_use content blocks
194
+ // - role:"tool" results converted and grouped into user messages with tool_result blocks
195
+ // - role:"user" and plain role:"assistant" pass through unchanged
196
+ function convertMessages(messages) {
197
+ const systemParts = [];
198
+ const converted = [];
199
+
200
+ for (let i = 0; i < messages.length; i++) {
201
+ const msg = messages[i];
202
+
203
+ if (msg.role === "system") {
204
+ if (typeof msg.content === "string" && msg.content) {
205
+ systemParts.push(msg.content);
206
+ }
207
+ continue;
208
+ }
209
+
210
+ // Group consecutive tool result messages into one user message
211
+ if (msg.role === "tool") {
212
+ const toolResults = [];
213
+ while (i < messages.length && messages[i].role === "tool") {
214
+ toolResults.push({
215
+ type: "tool_result",
216
+ tool_use_id: messages[i].tool_call_id,
217
+ content: messages[i].content || ""
218
+ });
219
+ i++;
220
+ }
221
+ i--; // outer loop will increment
222
+ converted.push({ role: "user", content: toolResults });
223
+ continue;
224
+ }
225
+
226
+ // Assistant with tool_calls → Anthropic tool_use content blocks
227
+ if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length) {
228
+ const content = [];
229
+ if (msg.content) { content.push({ type: "text", text: msg.content }); }
230
+ msg.tool_calls.forEach(function (tc) {
231
+ let input = {};
232
+ try { input = JSON.parse(tc.function.arguments || "{}"); } catch (e) {}
233
+ content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: input });
234
+ });
235
+ converted.push({ role: "assistant", content: content });
236
+ continue;
237
+ }
238
+
239
+ converted.push({ role: msg.role, content: msg.content });
240
+ }
241
+
242
+ return { system: systemParts.join("\n\n"), messages: converted };
243
+ }
244
+
245
+ function anthropicHeaders(settings) {
246
+ return {
247
+ "x-api-key": settings.apiKey || "",
248
+ "anthropic-version": ANTHROPIC_VERSION
249
+ };
250
+ }
251
+
252
+ function resolveBaseUrl(settings) {
253
+ return String(settings.baseUrl || ANTHROPIC_API_BASE).replace(/\/+$/, "") || ANTHROPIC_API_BASE;
254
+ }
255
+
256
+ // ---- chat ----
257
+
258
+ async function chat(settings, messages, options) {
259
+ if (!settings.model) { throw new Error("Model is required."); }
260
+ const baseUrl = resolveBaseUrl(settings);
261
+ const { system, messages: anthropicMessages } = convertMessages(messages);
262
+ const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
263
+
264
+ const body = {
265
+ model: settings.model,
266
+ messages: anthropicMessages,
267
+ max_tokens: DEFAULT_MAX_TOKENS,
268
+ temperature: temperature,
269
+ stream: false
270
+ };
271
+ if (system) { body.system = system; }
272
+ if (options && Array.isArray(options.tools) && options.tools.length) {
273
+ body.tools = options.tools.map(toAnthropicTool).filter(Boolean);
274
+ body.tool_choice = { type: "auto" };
275
+ }
276
+
277
+ const startedAt = Date.now();
278
+ const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000);
279
+ const totalMs = Date.now() - startedAt;
280
+
281
+ const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
282
+ const textContent = contentArray.filter(function (b) { return b.type === "text"; }).map(function (b) { return b.text; }).join("");
283
+ const toolUseBlocks = contentArray.filter(function (b) { return b.type === "tool_use"; });
284
+ const toolCalls = toolUseBlocks.length ? toolUseBlocks.map(toOpenAiToolCall) : null;
285
+ const usage = (response && response.usage)
286
+ ? { prompt_tokens: response.usage.input_tokens, completion_tokens: response.usage.output_tokens }
287
+ : null;
288
+
289
+ return {
290
+ raw: response,
291
+ content: textContent || (toolCalls ? "" : "[No assistant message returned by provider]"),
292
+ toolCalls: toolCalls,
293
+ timing: { totalMs: totalMs },
294
+ usage: usage
295
+ };
296
+ }
297
+
298
+ // ---- chatStream ----
299
+
300
+ async function chatStream(settings, messages, onDelta, onReasoningDelta) {
301
+ if (!settings.model) { throw new Error("Model is required."); }
302
+ const baseUrl = resolveBaseUrl(settings);
303
+ const { system, messages: anthropicMessages } = convertMessages(messages);
304
+ const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
305
+
306
+ const body = {
307
+ model: settings.model,
308
+ messages: anthropicMessages,
309
+ max_tokens: DEFAULT_MAX_TOKENS,
310
+ temperature: temperature,
311
+ stream: true
312
+ };
313
+ if (system) { body.system = system; }
314
+
315
+ const result = await postStream(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
316
+
317
+ return {
318
+ content: result.content || "",
319
+ timing: { ttftMs: result.ttftMs, totalMs: result.totalMs },
320
+ usage: result.usage
321
+ };
322
+ }
323
+
324
+ // ---- listModels ----
325
+ // Tries GET /v1/models; falls back to a hardcoded list if that endpoint
326
+ // is unavailable (non-standard proxy) or returns an error.
327
+ async function listModels(settings) {
328
+ const baseUrl = resolveBaseUrl(settings);
329
+ try {
330
+ const response = await getJson(baseUrl + "/v1/models", anthropicHeaders(settings), settings.requestTimeoutMs || 30000);
331
+ const data = response && Array.isArray(response.data) ? response.data : [];
332
+ const models = data.map(function (m) { return m && m.id; }).filter(function (id) { return typeof id === "string" && id; });
333
+ if (models.length) { return { models: models }; }
334
+ throw new Error("Empty model list from provider");
335
+ } catch (err) {
336
+ return {
337
+ models: [
338
+ "claude-opus-4-8",
339
+ "claude-sonnet-5",
340
+ "claude-haiku-4-5-20251001"
341
+ ],
342
+ error: err.message
343
+ };
344
+ }
345
+ }
346
+
347
+ // ---- probeTools ----
348
+ async function probeTools(settings) {
349
+ if (!settings.model) { throw new Error("Model is required."); }
350
+ const baseUrl = resolveBaseUrl(settings);
351
+
352
+ try {
353
+ const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), {
354
+ model: settings.model,
355
+ messages: [{ role: "user", content: "Call the \"ping\" tool now with no arguments." }],
356
+ system: "You are being tested for tool/function-calling support.",
357
+ tools: [{
358
+ name: "ping",
359
+ description: "Respond to a connectivity probe. Takes no arguments.",
360
+ input_schema: { type: "object", properties: {}, additionalProperties: false }
361
+ }],
362
+ tool_choice: { type: "auto" },
363
+ max_tokens: 128,
364
+ temperature: 0,
365
+ stream: false
366
+ }, settings.requestTimeoutMs || 30000);
367
+
368
+ const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
369
+ const hasToolUse = contentArray.some(function (b) { return b.type === "tool_use" && b.name === "ping"; });
370
+ return { supportsTools: hasToolUse };
371
+ } catch (err) {
372
+ return { supportsTools: false, error: err.message };
373
+ }
374
+ }
375
+
376
+ // ---- detectReasoning ----
377
+ // Extended thinking responses include content blocks with type:"thinking".
378
+ // Standard non-thinking responses never include them.
379
+ function detectReasoning(rawResponse) {
380
+ const contentArray = (rawResponse && Array.isArray(rawResponse.content)) ? rawResponse.content : [];
381
+ const isReasoningModel = contentArray.some(function (b) { return b && b.type === "thinking" && b.thinking; });
382
+ return { isReasoningModel: isReasoningModel };
383
+ }
384
+
385
+ module.exports = { chat, chatStream, probeTools, listModels, detectReasoning };
@@ -169,6 +169,9 @@ async function chat(settings, messages, options) {
169
169
  body.tools = options.tools;
170
170
  body.tool_choice = options.toolChoice || "auto";
171
171
  }
172
+ if (options && options.responseFormat) {
173
+ body.response_format = options.responseFormat;
174
+ }
172
175
 
173
176
  const startedAt = Date.now();
174
177
  const response = await postJson(`${baseUrl}/v1/chat/completions`, headers, body, settings.requestTimeoutMs || 180000);
@@ -194,7 +197,66 @@ async function chat(settings, messages, options) {
194
197
  // `data: [DONE]`). Calls onDelta(text) for each content fragment as it
195
198
  // arrives and resolves with { content } containing the full concatenated
196
199
  // text once the stream ends. Used for chat streaming only.
197
- function postStream(urlString, headers, body, timeoutMs, onDelta) {
200
+ // Splits a streaming content string on <think>...</think> tags so that
201
+ // reasoning text is routed to onReasoning and the actual response text to
202
+ // onContent. Handles tags that arrive split across multiple chunks via a
203
+ // small lookahead buffer. Handles both:
204
+ // - SGLang/Nemotron style: delta.reasoning_content (separate field)
205
+ // - llama.cpp/LocalAI style: <think>...</think> embedded in delta.content
206
+ // The two paths converge at onReasoningDelta in postStream — callers see
207
+ // one uniform reasoning callback regardless of provider format.
208
+ function createThinkTagSplitter(onContent, onReasoning) {
209
+ let phase = "seek"; // "seek" | "think" | "content"
210
+ let buf = "";
211
+ const OPEN = "<think>";
212
+ const CLOSE = "</think>";
213
+
214
+ function push(text) {
215
+ buf += text;
216
+ while (buf.length > 0) {
217
+ if (phase === "seek") {
218
+ const idx = buf.indexOf(OPEN);
219
+ if (idx === -1) {
220
+ // No opening tag visible — emit everything except the last few bytes
221
+ // that might be a partial tag, buffer the rest.
222
+ const safe = buf.length > OPEN.length - 1 ? buf.length - (OPEN.length - 1) : 0;
223
+ if (safe > 0) { onContent(buf.slice(0, safe)); buf = buf.slice(safe); }
224
+ break;
225
+ }
226
+ if (idx > 0) { onContent(buf.slice(0, idx)); }
227
+ buf = buf.slice(idx + OPEN.length);
228
+ phase = "think";
229
+ } else if (phase === "think") {
230
+ const idx = buf.indexOf(CLOSE);
231
+ if (idx === -1) {
232
+ const safe = buf.length > CLOSE.length - 1 ? buf.length - (CLOSE.length - 1) : 0;
233
+ if (safe > 0) { onReasoning(buf.slice(0, safe)); buf = buf.slice(safe); }
234
+ break;
235
+ }
236
+ if (idx > 0) { onReasoning(buf.slice(0, idx)); }
237
+ buf = buf.slice(idx + CLOSE.length);
238
+ // Skip whitespace/newline immediately after </think>
239
+ const trimmed = buf.replace(/^\s+/, "");
240
+ buf = trimmed;
241
+ phase = "content";
242
+ } else {
243
+ onContent(buf);
244
+ buf = "";
245
+ break;
246
+ }
247
+ }
248
+ }
249
+
250
+ function finish() {
251
+ if (!buf) { return; }
252
+ if (phase === "think") { onReasoning(buf); } else { onContent(buf); }
253
+ buf = "";
254
+ }
255
+
256
+ return { push, finish };
257
+ }
258
+
259
+ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDelta) {
198
260
  return new Promise((resolve, reject) => {
199
261
  let url;
200
262
  try {
@@ -233,12 +295,22 @@ function postStream(urlString, headers, body, timeoutMs, onDelta) {
233
295
  return;
234
296
  }
235
297
 
236
- let buf = "";
298
+ let sseBuf = "";
237
299
  let full = "";
300
+ // When onReasoningDelta is provided, intercept <think>...</think> from
301
+ // delta.content in addition to the dedicated delta.reasoning_content field
302
+ // (llama.cpp/LocalAI style vs SGLang/Nemotron style — both converge here).
303
+ const thinkSplitter = onReasoningDelta
304
+ ? createThinkTagSplitter(
305
+ function (c) { if (firstTokenAt === null) { firstTokenAt = Date.now(); } full += c; onDelta(c); },
306
+ onReasoningDelta
307
+ )
308
+ : null;
309
+
238
310
  res.on("data", (chunk) => {
239
- buf += chunk;
240
- const lines = buf.split("\n");
241
- buf = lines.pop(); // keep the last (possibly partial) line for next time
311
+ sseBuf += chunk;
312
+ const lines = sseBuf.split("\n");
313
+ sseBuf = lines.pop(); // keep the last (possibly partial) line for next time
242
314
 
243
315
  lines.forEach((line) => {
244
316
  line = line.trim();
@@ -255,17 +327,29 @@ function postStream(urlString, headers, body, timeoutMs, onDelta) {
255
327
 
256
328
  if (evt && evt.usage) { usage = evt.usage; }
257
329
 
258
- const delta = evt && evt.choices && evt.choices[0] && evt.choices[0].delta
259
- ? evt.choices[0].delta.content
260
- : "";
261
- if (delta) {
262
- if (firstTokenAt === null) { firstTokenAt = Date.now(); }
263
- full += delta;
264
- onDelta(delta);
330
+ const deltaObj = evt && evt.choices && evt.choices[0] && evt.choices[0].delta;
331
+ if (deltaObj) {
332
+ if (deltaObj.content) {
333
+ if (thinkSplitter) {
334
+ thinkSplitter.push(deltaObj.content);
335
+ } else {
336
+ if (firstTokenAt === null) { firstTokenAt = Date.now(); }
337
+ full += deltaObj.content;
338
+ onDelta(deltaObj.content);
339
+ }
340
+ }
341
+ // Separate reasoning_content field (SGLang/Nemotron style). Both
342
+ // paths run in parallel — they are mutually exclusive per provider
343
+ // (llama.cpp puts reasoning in delta.content via <think> tags;
344
+ // SGLang puts it in delta.reasoning_content with delta.content "").
345
+ if (deltaObj.reasoning_content && onReasoningDelta) {
346
+ onReasoningDelta(deltaObj.reasoning_content);
347
+ }
265
348
  }
266
349
  });
267
350
  });
268
351
  res.on("end", () => {
352
+ if (thinkSplitter) { thinkSplitter.finish(); }
269
353
  const endedAt = Date.now();
270
354
  resolve({
271
355
  content: full,
@@ -287,7 +371,7 @@ function postStream(urlString, headers, body, timeoutMs, onDelta) {
287
371
  });
288
372
  }
289
373
 
290
- async function chatStream(settings, messages, onDelta) {
374
+ async function chatStream(settings, messages, onDelta, onReasoningDelta, options) {
291
375
  const baseUrl = String(settings.baseUrl || "").replace(/\/+$/, "");
292
376
  if (!baseUrl) throw new Error("Provider base URL is required.");
293
377
  if (!settings.model) throw new Error("Model is required.");
@@ -303,13 +387,19 @@ async function chatStream(settings, messages, onDelta) {
303
387
  // final SSE chunk carrying token usage (no delta) before [DONE]. Providers
304
388
  // that don't support it just ignore the option; postStream treats a
305
389
  // missing usage field as null either way.
306
- const result = await postStream(`${baseUrl}/v1/chat/completions`, headers, {
390
+ const body = {
307
391
  model: settings.model,
308
392
  messages,
309
393
  temperature,
310
394
  stream: true,
311
395
  stream_options: { include_usage: true }
312
- }, settings.requestTimeoutMs || 180000, onDelta);
396
+ };
397
+ if (options && options.responseFormat) {
398
+ body.response_format = options.responseFormat;
399
+ }
400
+
401
+ const result = await postStream(`${baseUrl}/v1/chat/completions`, headers,
402
+ body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
313
403
 
314
404
  return {
315
405
  content: result.content || "",
@@ -377,4 +467,20 @@ async function probeTools(settings) {
377
467
  }
378
468
  }
379
469
 
380
- module.exports = { chat, chatStream, probeTools, listModels };
470
+ // ---------------------------------------------------------------------
471
+ // Checks whether the provider's response includes reasoning_content —
472
+ // the separate chain-of-thought field emitted by reasoning models
473
+ // (e.g. Nemotron, DeepSeek-R1, o1-style). Reuses the connectivity test
474
+ // response already obtained by the /test route rather than making a
475
+ // second round-trip: caller passes the raw response object.
476
+ // Returns { isReasoningModel: boolean }.
477
+ // ---------------------------------------------------------------------
478
+ function detectReasoning(rawResponse) {
479
+ const message = rawResponse && rawResponse.choices && rawResponse.choices[0]
480
+ && rawResponse.choices[0].message;
481
+ const isReasoningModel = !!(message && message.reasoning_content != null
482
+ && message.reasoning_content !== "");
483
+ return { isReasoningModel };
484
+ }
485
+
486
+ module.exports = { chat, chatStream, probeTools, listModels, detectReasoning };
package/lib/storage.js CHANGED
@@ -18,10 +18,15 @@ function createStorage(userDir) {
18
18
  return {
19
19
  id: "default",
20
20
  providerName: "LocalAI",
21
+ // "openai-compatible" (default) or "anthropic"
22
+ type: "openai-compatible",
21
23
  baseUrl: "http://localhost:8080",
22
24
  apiKey: "",
23
25
  model: "",
24
- temperature: 0.2
26
+ temperature: 0.2,
27
+ // Configured context window size for this provider in tokens (0 = unknown).
28
+ // When set, FlowPilot warns when the assembled prompt approaches the limit.
29
+ numCtx: 0
25
30
  };
26
31
  }
27
32
 
@@ -33,7 +38,11 @@ function createStorage(userDir) {
33
38
  maxContextChars: 12000,
34
39
  defaultContextMode: "selected",
35
40
  allowConfigContext: false,
36
- logFullContext: false,
41
+ // When true, each assembled prompt (post-redaction messages array) plus the
42
+ // raw provider response and parse outcome is appended to assembled-prompts.log
43
+ // (0600 perms). Auth headers/keys are never logged — only the content bytes
44
+ // that the provider actually received. Off by default (diagnostic tool).
45
+ logAssembledPrompts: false,
37
46
  streamingEnabled: true,
38
47
  // First-run welcome/warning shows until the user saves settings once.
39
48
  firstRunAcknowledged: false,
@@ -62,6 +71,10 @@ function createStorage(userDir) {
62
71
  // and shows a checkpoint question ("Continue with AI review, or stop?")
63
72
  // instead of auto-advancing. Default false = original auto-advance behavior.
64
73
  loopHoldStep: false,
74
+ // Routes Generate through the Phase 10 step-queue engine (graph read-back
75
+ // verification after import). The legacy path remains available when a
76
+ // user explicitly disables this setting.
77
+ enableStepQueue: true,
65
78
  // Lets the user silence the recurring secrets/size reminder bar after
66
79
  // typing an explicit acknowledgement in settings.
67
80
  suppressContextWarnings: false,
@@ -290,6 +303,32 @@ function createStorage(userDir) {
290
303
  return defaultSettings.systemPrompt;
291
304
  }
292
305
 
306
+ const assembledPromptsFile = path.join(baseDir, "assembled-prompts.log");
307
+
308
+ // W0.4: Append one JSON-lines entry to assembled-prompts.log.
309
+ // Written 0600 — diagnostic data, never world-readable.
310
+ // Auth keys are NOT included (only baseUrl + model from the provider
311
+ // profile, never apiKey). The bytes logged are post-redaction: the same
312
+ // content the provider actually received.
313
+ function appendAssembledPromptLog(entry) {
314
+ init();
315
+ const line = JSON.stringify({
316
+ timestamp: new Date().toISOString(),
317
+ ...entry
318
+ });
319
+ try {
320
+ // Open with O_APPEND | O_CREAT, mode 0600 so secrets never land
321
+ // in a world-readable file even on first write.
322
+ const fd = fs.openSync(assembledPromptsFile, "a", 0o600);
323
+ fs.writeSync(fd, line + "\n");
324
+ fs.closeSync(fd);
325
+ // Ensure 0600 regardless of umask on subsequent opens.
326
+ fs.chmodSync(assembledPromptsFile, 0o600);
327
+ } catch (err) {
328
+ console.error("[FlowPilot] assembled-prompts log write failed:", err.message);
329
+ }
330
+ }
331
+
293
332
  init();
294
333
 
295
334
  return {
@@ -298,11 +337,13 @@ function createStorage(userDir) {
298
337
  backupsDir,
299
338
  settingsFile,
300
339
  auditFile,
340
+ assembledPromptsFile,
301
341
  getSettings,
302
342
  saveSettings,
303
343
  getActiveProvider,
304
344
  getDefaultSystemPrompt,
305
345
  appendAudit,
346
+ appendAssembledPromptLog,
306
347
  appendTranscript,
307
348
  readTranscript,
308
349
  deleteTranscript,