@manny-est/node-red-flowpilot 0.6.0 → 0.6.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,66 @@
2
2
 
3
3
  All notable changes to FlowPilot are documented here.
4
4
 
5
+ ## [0.6.2] - 2026-09-22
6
+
7
+ ### Fixed
8
+ - **Newer Anthropic models (Sonnet 5, Fable 5.1) didn't work at all —
9
+ every request failed with a 400 error.** ([#9](https://github.com/manny-est/flowpilot/issues/9))
10
+ Models released after Claude Opus 4.6 reject any `temperature` value
11
+ other than 1.0; FlowPilot's default of 0.2 meant every single chat,
12
+ generate, modify, document, and provider-capability-probe request
13
+ against a newer model failed outright with "temperature is deprecated
14
+ for this model." Older models (confirmed: `claude-sonnet-4-5`) were
15
+ unaffected. Fixed reactively — no model-name list to maintain: FlowPilot
16
+ now detects that exact error live, retries once with `temperature`
17
+ omitted, and remembers the model needs that treatment so it's a
18
+ one-time cost. Live-verified against Sonnet 5, Fable 5.1, and an older
19
+ model to confirm no regression there.
20
+ - **A multi-section plan could silently lose most of its steps from the
21
+ todo checklist.** A model's own explanation can legitimately structure
22
+ a larger plan as several numbered sections under short labels (e.g. a
23
+ Dashboard 2.0 build broken into "Widgets to add:" / "Final steps:")
24
+ rather than one unbroken numbered list. The checklist parser stopped
25
+ reading at the first blank line, so only the first section's items
26
+ ever made it onto the checklist — a 5-step plan could show as a
27
+ 1-item checklist, which read as "the numbering is wrong" but was
28
+ actually silent data loss. Now keeps reading across section breaks and
29
+ only stops at the real closing explanation paragraph.
30
+ - **No way to clear a pinned node selection without losing your place.**
31
+ Once a mode (Generate/Modify/Document/Build) is armed with a
32
+ selection, that selection intentionally stays attached across
33
+ follow-up turns so you don't have to reselect every time — but there
34
+ was no way to just drop it if you no longer wanted those nodes
35
+ attached, short of fully re-arming the mode or clearing the whole
36
+ chat. The status line now shows a small ✕ next to a pinned selection
37
+ that clears just the pin, leaving the mode itself armed.
38
+
39
+ ## [0.6.1] - 2026-09-02
40
+
41
+ ### Fixed
42
+ - **Deterministic run summaries actually work now.** 0.6.0 shipped this
43
+ feature — the per-item ✓/✗ outcome from a Modify or Generate run,
44
+ built from what the WRITE tools actually reported instead of the
45
+ model's own retelling — but a control-flow bug in the message-
46
+ rendering path made it unreachable for every real run: the code path
47
+ that shows the deterministic summary as primary (with the model's
48
+ own explanation demoted underneath) never executed. Every response
49
+ fell through to showing the model's own prose at full weight
50
+ instead, exactly the narrative-accuracy gap this feature exists to
51
+ close. Found and fixed during this release's own go-live testing,
52
+ now live-verified against a real agentic write.
53
+ - **Installed-package awareness was silently non-functional — FlowPilot
54
+ now actually receives your palette.** The feature that keeps
55
+ Generate/Modify from proposing node types you don't have, and lets
56
+ FlowPilot correctly answer "is X installed?", called a Node-RED API
57
+ that doesn't exist on this version's plugin interface. The call was
58
+ a silent no-op on every single request: FlowPilot never had real
59
+ palette information to work with, so the model either said as much
60
+ or, in some cases, guessed. This wasn't a timing bug or a stale
61
+ cache — the underlying data source itself never worked. Now reads
62
+ the live node registry directly and is correct immediately, from
63
+ the very first request after a restart.
64
+
5
65
  ## [0.6.0] - 2026-09-01
6
66
 
7
67
  Promoted from `0.6.0-beta.1` to the stable `latest` npm tag.
package/flowpilot.js CHANGED
@@ -1,6 +1,6 @@
1
- const http = require("http");
2
1
  const https = require("https");
3
2
  const path = require("path");
3
+ const nodeRedRegistry = require("@node-red/registry");
4
4
  const PACKAGE_VERSION = require("./package.json").version;
5
5
  const createStorage = require("./lib/storage");
6
6
  const openaiProvider = require("./lib/provider-openai-compatible");
@@ -189,7 +189,47 @@ async function performUpdateCheck() {
189
189
  }
190
190
  }
191
191
 
192
+ // Found live (0.6.1), while verifying the palette-cache fix below: Node-RED
193
+ // loads flowpilot.js TWICE per process start, and always has — package.json
194
+ // declares BOTH "nodes": {"flowpilot": "flowpilot-node-entry.js"} (itself a
195
+ // shim added earlier this same release for a related but distinct bug — see
196
+ // that shim's own comment) AND "plugins": {"flowpilot": "flowpilot.html"}.
197
+ // @node-red/registry/lib/loader.js's loadPluginConfig derives a companion
198
+ // runtime file for every "plugins" entry via the SAME basename-replace
199
+ // pattern (file.replace(/\.[^.]+$/,".js")) that caused the earlier
200
+ // flowpilot.html double-load — "flowpilot.html" -> "flowpilot.js", which
201
+ // genuinely exists, so Node-RED loads and calls it a second time completely
202
+ // independently of the "nodes" entry's shim. Confirmed live: a temporary
203
+ // diagnostic showed flowPilotRuntime executing twice per restart, and a
204
+ // registered RED.events.on("nodes-started", ...) listener firing twice.
205
+ // Unlike the client-side html/script duplication, this one can't be closed
206
+ // by renaming what "plugins" points to without duplicating flowpilot.html's
207
+ // content under a second filename — not worth the maintenance burden for
208
+ // what's fundamentally the same fix in spirit. Guard here instead.
209
+ //
210
+ // A first attempt at this guard used a plain module-level `let` flag,
211
+ // reasoning that Node's require() cache makes a module's own top-level code
212
+ // run exactly once. That reasoning doesn't hold here: confirmed by reading
213
+ // @node-red/registry/lib/loader.js directly, the "nodes" entry is loaded via
214
+ // loadNodeSet, which uses a dynamic import() of a file:// URL (see its
215
+ // `pathToFileURL(node.file)` call), while the "plugins" entry's companion
216
+ // file is loaded via loadPlugin, which uses a plain require(). Node's CJS
217
+ // require() cache and its ESM import() module cache are not the same
218
+ // cache — a module reached through both paths can genuinely execute its
219
+ // top-level code twice, each with its OWN independent closure (own
220
+ // `let flowPilotRuntimeInitialized`, own everything). Confirmed live: a
221
+ // module-level flag did NOT stop the second invocation's independent
222
+ // httpAdmin route registrations from being the ones actually serving
223
+ // requests, while the diagnostic-carrying instance sat silent.
224
+ //
225
+ // Fix: mark completion on `global` instead. Node's global object is a
226
+ // true process-wide singleton — unaffected by which loader/module-cache
227
+ // reached this file — so it's a reliable gate regardless of how many
228
+ // separate module realms this file's code ends up evaluated in.
192
229
  module.exports = function flowPilotRuntime(RED) {
230
+ if (global.__flowPilotRuntimeInitialized) { return; }
231
+ global.__flowPilotRuntimeInitialized = true;
232
+
193
233
  const storage = createStorage(RED.settings.userDir);
194
234
 
195
235
  // ---------------------------------------------------------------------
@@ -703,17 +743,42 @@ module.exports = function flowPilotRuntime(RED) {
703
743
  // their node types when relevant and otherwise stick to core nodes
704
744
  // rather than proposing types that aren't installed.
705
745
  //
706
- // The node-level RED API passed to this module has no direct registry
707
- // lookup (no RED.nodes.getNodeList), so the node list is fetched via a
708
- // loopback call to Node-RED's own admin API (the same data the palette
709
- // sidebar uses) and cached briefly — the palette rarely changes, and
710
- // every chat/generate/document/modify request goes through
711
- // buildMessages, so this must stay cheap and synchronous.
746
+ // Node-RED's admin GET /nodes route ultimately reads the runtime registry's
747
+ // getNodeList(). A previous fix tried to reach that through RED.nodes, but
748
+ // Node-RED 5's @node-red/registry/lib/util.js createNodeApi only copies a
749
+ // small allow-list onto the node-level RED API (createNode/getNode/
750
+ // eachNode/addCredentials/getCredentials/deleteCredentials)
751
+ // getNodeList is NOT one of them. Confirmed live: RED.nodes.getNodeList is
752
+ // undefined in this runtime, so describeInstalledNodes() quietly returned
753
+ // null from its catch on every request even after flows:started had fired.
754
+ //
755
+ // Use the registry module directly instead. FlowPilot already runs inside
756
+ // the same Node-RED process, and @node-red/registry is the exact source
757
+ // runtime.nodes.getNodeList() delegates to.
758
+ //
759
+ // Found live (0.6.1): this WAS a cached snapshot, through three
760
+ // successive designs (a naive restart-seeded TTL timer; a
761
+ // "nodes-started"-gated refresh; that plus a bounded settle re-check on
762
+ // top), because the original data source was an HTTP loopback to this
763
+ // same instance's own /nodes admin route — expensive enough to be worth
764
+ // caching, and, on any adminAuth-enabled instance, unable to
765
+ // authenticate itself at all (a real bug, fixed by switching to the
766
+ // in-process RED.nodes.getNodeList() call below). Once the data source
767
+ // is a synchronous, already-in-memory function call, caching it stops
768
+ // paying for itself — it only adds staleness/timing bugs, three of
769
+ // which got live-found in a row this same afternoon (the gate never
770
+ // set; set but the fetch 401ing; set and fetched but still an early
771
+ // snapshot for a slow-registering package like uibuilder). The one
772
+ // thing still worth gating is genuinely knowing nothing yet: before
773
+ // Node-RED's own "flows:started" event (the non-deprecated alias of
774
+ // "nodes-started", confirmed emitted from
775
+ // @node-red/runtime/lib/flows/index.js right after every active flow's
776
+ // own start() resolves) has fired at least once,
777
+ // describeInstalledNodes() returns null — "don't know yet" instead of a
778
+ // false "confirmed absent."
712
779
  // ---------------------------------------------------------------------
713
- let installedNodesCache = null;
714
- let installedNodesCacheAt = 0;
715
- let installedNodesRefreshInFlight = false;
716
- const INSTALLED_NODES_CACHE_TTL_MS = 5 * 60 * 1000;
780
+ let nodesRegistryReady = false;
781
+ RED.events.on("flows:started", function () { nodesRegistryReady = true; });
717
782
 
718
783
  function buildInstalledNodesContent(list) {
719
784
  if (!Array.isArray(list)) { return null; }
@@ -752,39 +817,13 @@ module.exports = function flowPilotRuntime(RED) {
752
817
  return content;
753
818
  }
754
819
 
755
- function refreshInstalledNodesCache() {
756
- if (installedNodesRefreshInFlight) { return; }
757
- installedNodesRefreshInFlight = true;
758
-
759
- const root = String(RED.settings.httpAdminRoot || "/").replace(/\/+$/, "");
760
- const req = http.get({
761
- host: "127.0.0.1",
762
- port: RED.settings.uiPort,
763
- path: root + "/nodes",
764
- headers: { Accept: "application/json" },
765
- timeout: 5000
766
- }, function (res) {
767
- const chunks = [];
768
- res.on("data", function (chunk) { chunks.push(chunk); });
769
- res.on("end", function () {
770
- installedNodesRefreshInFlight = false;
771
- if (res.statusCode !== 200) { return; }
772
- try {
773
- const list = JSON.parse(Buffer.concat(chunks).toString("utf8"));
774
- installedNodesCache = buildInstalledNodesContent(list);
775
- installedNodesCacheAt = Date.now();
776
- } catch (err) { /* leave previous cache value in place */ }
777
- });
778
- });
779
- req.on("error", function () { installedNodesRefreshInFlight = false; });
780
- req.on("timeout", function () { req.destroy(); installedNodesRefreshInFlight = false; });
781
- }
782
-
783
820
  function describeInstalledNodes() {
784
- if (Date.now() - installedNodesCacheAt > INSTALLED_NODES_CACHE_TTL_MS) {
785
- refreshInstalledNodesCache();
821
+ if (!nodesRegistryReady) { return null; }
822
+ try {
823
+ return buildInstalledNodesContent(nodeRedRegistry.getNodeList());
824
+ } catch (err) {
825
+ return null;
786
826
  }
787
- return installedNodesCache;
788
827
  }
789
828
 
790
829
  // Chat-only: the user's base system prompt plus a freshly-generated
package/lib/core/main.js CHANGED
@@ -2482,9 +2482,25 @@
2482
2482
  : armedExecuteAction === "document" ? "Document"
2483
2483
  : armedExecuteAction === "modify" ? "Modify"
2484
2484
  : armedExecuteAction === "build" ? "Build" : "Execute";
2485
- $status.text("Pinned: " + count + (count === 1 ? " node" : " nodes") +
2486
- " for " + actionLabel + " — will be sent as context")
2487
- .addClass("fp-has-selection");
2485
+ // 0.6.2 hotfix (pinned nodes don't clear): a pin is coupled to
2486
+ // armedExecuteAction by design (persists across follow-up turns
2487
+ // in the same mode so the user doesn't need to reselect every
2488
+ // time) — that part is intentional, not a bug. What was
2489
+ // actually missing is any way to clear JUST the pin, short of
2490
+ // re-arming/disarming the mode entirely or Clear Chat. Mirrors
2491
+ // updateDebugStatus's own "attached context + a ✕ to drop it"
2492
+ // pattern (main-window only, same as Modify/Build's other
2493
+ // live-RED.*-state features) rather than a plain text label.
2494
+ $status.empty().addClass("fp-has-selection");
2495
+ $status.append(document.createTextNode("Pinned: " + count + (count === 1 ? " node" : " nodes") +
2496
+ " for " + actionLabel + " — will be sent as context "));
2497
+ var $clearPin = $("<a>").attr("href", "#").text("✕").attr("title", "Clear pinned node context (stays in " + actionLabel + " mode)");
2498
+ $clearPin.on("click", function (ev) {
2499
+ ev.preventDefault();
2500
+ pinnedSelectionIds = null;
2501
+ updateSelectionStatus();
2502
+ });
2503
+ $status.append($clearPin);
2488
2504
  } else {
2489
2505
  $status.text(count + (count === 1 ? " node" : " nodes") + groupNote +
2490
2506
  " selected — will be sent as context")
package/lib/core/modes.js CHANGED
@@ -1259,7 +1259,19 @@
1259
1259
  updateSelectionStatus();
1260
1260
  return true;
1261
1261
  }
1262
- if (data.prose) {
1262
+ // finalizeSimpleGeneration/finalizeModifyResult set prose:true on
1263
+ // EVERY agent-strategy final response, whether or not real
1264
+ // WRITE-tool work happened this run — it's the server's generic
1265
+ // "no classic flow/changes to review" signal, not a claim that
1266
+ // nothing happened. When real write results are attached
1267
+ // (data._agentWriteResults, added client-side in handleStep before
1268
+ // onDone fires), the caller's own deterministic-summary rendering
1269
+ // needs to run instead of this generic prose bubble — bug found
1270
+ // live during the 0.6.0 go-live smoke test: this unconditional
1271
+ // early-return made C1's entire deterministic-summary/prose-
1272
+ // demotion feature unreachable for every real agent-strategy run.
1273
+ var hasRealWriteResults = Array.isArray(data._agentWriteResults) && data._agentWriteResults.length > 0;
1274
+ if (data.prose && !hasRealWriteResults) {
1263
1275
  if (looksLikeToolEnvelope(data.explanation)) {
1264
1276
  handleExecuteError("FlowPilot's reply didn't come through as expected.", data.explanation);
1265
1277
  updateSelectionStatus();
@@ -1320,7 +1332,7 @@
1320
1332
  // executable flows (not documentation-only comment nodes) and when no
1321
1333
  // loop is already active. The secondary "Just add to canvas" button is
1322
1334
  // always shown alongside it as an escape hatch.
1323
- var _hasDeployable = flow.some(function (n) { return n && n.type !== "comment" && n.type !== "group"; });
1335
+ var _hasDeployable = (flow || []).some(function (n) { return n && n.type !== "comment" && n.type !== "group"; });
1324
1336
  var _buildOnImported = (goalPrompt && !activeBuildLoop && _hasDeployable)
1325
1337
  ? function (importResult) { startBuildLoop(goalPrompt, flow, importResult); }
1326
1338
  : null;
@@ -1663,21 +1675,46 @@
1663
1675
 
1664
1676
  // W4: parse a "Plan:" block from the model's explanation field.
1665
1677
  // Returns an array of { text, status } items, or [] if none found.
1666
- // Each numbered/bulleted line under "Plan:" up to the first blank line
1667
- // becomes one item. Status starts as "pending" for all items — the
1668
- // caller sets the first to "active" before rendering.
1678
+ // Status starts as "pending" for all items — the caller sets the first
1679
+ // to "active" before rendering.
1680
+ //
1681
+ // 0.6.2 hotfix (todo-list numbering bug): this used to stop at the
1682
+ // FIRST blank line after "Plan:", on the assumption a plan is always
1683
+ // one unbroken numbered block followed by closing prose. A real
1684
+ // Dashboard 2.0 explanation can legitimately structure its plan as
1685
+ // several numbered sections separated by short labels ("Widgets to
1686
+ // add:", "Final steps:") — each its own blank-line-delimited block —
1687
+ // and the old logic silently dropped every item after the first
1688
+ // section (observed as "wrong numbering": a 5-item plan producing a
1689
+ // 1-item checklist). Now scans every line after "Plan:" to the end of
1690
+ // the explanation and keeps collecting numbered/bulleted lines across
1691
+ // blank lines and short one-line section labels; it only stops at two
1692
+ // CONSECUTIVE non-list, non-blank lines — a real prose paragraph, the
1693
+ // closing explanation that follows every plan today. A single-line
1694
+ // label between numbered sections never counts as that paragraph, so
1695
+ // sections keep getting picked up; a genuine multi-line prose block
1696
+ // still ends collection exactly as before.
1669
1697
  function parseTodoPlan(explanation) {
1670
1698
  if (!explanation || typeof explanation !== "string") { return []; }
1671
1699
  var planStart = explanation.indexOf("Plan:");
1672
1700
  if (planStart === -1) { return []; }
1673
1701
  var afterPlan = explanation.slice(planStart + 5);
1674
- var planBlock = afterPlan.split(/\n\n/)[0];
1675
- var lines = planBlock.split("\n");
1702
+ var lines = afterPlan.split("\n");
1676
1703
  var items = [];
1677
- lines.forEach(function (line) {
1678
- var stripped = line.replace(/^\s*\d+[.):\s]+/, "").replace(/^\s*[-*]\s+/, "").trim();
1679
- if (stripped) { items.push({ text: stripped, status: "pending" }); }
1680
- });
1704
+ var proseStreak = 0;
1705
+ for (var i = 0; i < lines.length; i++) {
1706
+ var line = lines[i];
1707
+ if (!line.trim()) { continue; } // a blank line alone never ends the plan
1708
+ var isListLine = /^\s*\d+[.):\s]/.test(line) || /^\s*[-*]\s+/.test(line);
1709
+ if (isListLine) {
1710
+ proseStreak = 0;
1711
+ var stripped = line.replace(/^\s*\d+[.):\s]+/, "").replace(/^\s*[-*]\s+/, "").trim();
1712
+ if (stripped) { items.push({ text: stripped, status: "pending" }); }
1713
+ continue;
1714
+ }
1715
+ proseStreak++;
1716
+ if (proseStreak >= 2) { break; }
1717
+ }
1681
1718
  return items;
1682
1719
  }
1683
1720
 
@@ -2019,7 +2056,7 @@
2019
2056
  // also runs verifyImportedNodes so the todo record still gets checked
2020
2057
  // off. Without a build loop, fall back to a plain "Add to canvas"
2021
2058
  // button that still fires the verify callback.
2022
- var _hasDeployable = flow.some(function (n) { return n && n.type !== "comment" && n.type !== "group"; });
2059
+ var _hasDeployable = (flow || []).some(function (n) { return n && n.type !== "comment" && n.type !== "group"; });
2023
2060
  var _wantLoop = goalPrompt && !activeBuildLoop && _hasDeployable;
2024
2061
  var _onImported = _wantLoop
2025
2062
  ? function (importResult) {
@@ -6,6 +6,28 @@ const ANTHROPIC_API_BASE = "https://api.anthropic.com";
6
6
  const ANTHROPIC_VERSION = "2023-06-01";
7
7
  const DEFAULT_MAX_TOKENS = 8192;
8
8
 
9
+ // ---- temperature-deprecated detection (GitHub #9) ----
10
+ // Models released after Claude Opus 4.6 reject any temperature value other
11
+ // than 1.0 (Anthropic's own Messages API docs) — FlowPilot's default is 0.2,
12
+ // so every request against a newer model fails outright with no reactive
13
+ // handling. No model-name allowlist or version parsing: detect the actual
14
+ // error shape live and adapt. In-memory only (module scope, keyed by model
15
+ // name) — resets on a server restart, which is an acceptable, deliberate
16
+ // simplification for a hotfix; the storage-backed persistence
17
+ // `supportsTools` uses would need threading through every one of this
18
+ // module's ~8 call sites in flowpilot.js and isn't worth that churn for a
19
+ // per-process cache that already makes this a one-time cost per model for
20
+ // the life of the running server.
21
+ const modelsWithoutTemperatureSupport = new Map();
22
+
23
+ function isTemperatureDeprecatedError(err) {
24
+ if (!err || typeof err.message !== "string") { return false; }
25
+ const msg = err.message;
26
+ return msg.indexOf("invalid_request_error") !== -1 &&
27
+ msg.indexOf("temperature") !== -1 &&
28
+ msg.indexOf("deprecated") !== -1;
29
+ }
30
+
9
31
  // ---- HTTP helpers ----
10
32
 
11
33
  function postJson(urlString, headers, body, timeoutMs) {
@@ -109,8 +131,19 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
109
131
  }, (res) => {
110
132
  res.setEncoding("utf8");
111
133
  if (res.statusCode < 200 || res.statusCode >= 300) {
112
- res.on("data", () => {});
113
- res.on("end", () => { reject(new Error("Provider request failed (status " + res.statusCode + ").")); });
134
+ // Capture the error body (same shape as postJson's non-2xx path)
135
+ // so callers can detect a specific error type (e.g. the
136
+ // temperature-deprecated case below) — this stays internal to the
137
+ // retry decision, never echoed to the HTTP client as the raw body
138
+ // (same ADR-007 boundary postJson already holds).
139
+ let errBody = "";
140
+ res.on("data", (chunk) => { errBody += chunk; });
141
+ res.on("end", () => {
142
+ let parsed = null;
143
+ try { parsed = errBody ? JSON.parse(errBody) : null; } catch (e) { /* fall through to generic message */ }
144
+ const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : ("status " + res.statusCode);
145
+ reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
146
+ });
114
147
  return;
115
148
  }
116
149
 
@@ -263,23 +296,37 @@ async function chat(settings, messages, options) {
263
296
  const { system, messages: anthropicMessages } = convertMessages(messages);
264
297
  const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
265
298
 
266
- const body = {
267
- model: settings.model,
268
- messages: anthropicMessages,
269
- max_tokens: DEFAULT_MAX_TOKENS,
270
- temperature: temperature,
271
- stream: false
272
- };
273
- if (system) { body.system = system; }
274
- if (options && Array.isArray(options.tools) && options.tools.length) {
275
- body.tools = options.tools.map(toAnthropicTool).filter(Boolean);
276
- body.tool_choice = {
277
- type: options.toolChoice === "required" ? "any" : "auto"
299
+ function buildBody(includeTemperature) {
300
+ const body = {
301
+ model: settings.model,
302
+ messages: anthropicMessages,
303
+ max_tokens: DEFAULT_MAX_TOKENS,
304
+ stream: false
278
305
  };
306
+ if (includeTemperature) { body.temperature = temperature; }
307
+ if (system) { body.system = system; }
308
+ if (options && Array.isArray(options.tools) && options.tools.length) {
309
+ body.tools = options.tools.map(toAnthropicTool).filter(Boolean);
310
+ body.tool_choice = {
311
+ type: options.toolChoice === "required" ? "any" : "auto"
312
+ };
313
+ }
314
+ return body;
279
315
  }
280
316
 
317
+ const skipTemperature = modelsWithoutTemperatureSupport.get(settings.model) === true;
281
318
  const startedAt = Date.now();
282
- const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000);
319
+ let response;
320
+ try {
321
+ response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), buildBody(!skipTemperature), settings.requestTimeoutMs || 180000);
322
+ } catch (err) {
323
+ if (skipTemperature || !isTemperatureDeprecatedError(err)) { throw err; }
324
+ // First time seeing this on this model this process — cache it so
325
+ // every later call to this model skips straight to the no-temperature
326
+ // body, then retry this one request once.
327
+ modelsWithoutTemperatureSupport.set(settings.model, true);
328
+ response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), buildBody(false), settings.requestTimeoutMs || 180000);
329
+ }
283
330
  const totalMs = Date.now() - startedAt;
284
331
 
285
332
  const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
@@ -307,16 +354,31 @@ async function chatStream(settings, messages, onDelta, onReasoningDelta) {
307
354
  const { system, messages: anthropicMessages } = convertMessages(messages);
308
355
  const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
309
356
 
310
- const body = {
311
- model: settings.model,
312
- messages: anthropicMessages,
313
- max_tokens: DEFAULT_MAX_TOKENS,
314
- temperature: temperature,
315
- stream: true
316
- };
317
- if (system) { body.system = system; }
357
+ function buildBody(includeTemperature) {
358
+ const body = {
359
+ model: settings.model,
360
+ messages: anthropicMessages,
361
+ max_tokens: DEFAULT_MAX_TOKENS,
362
+ stream: true
363
+ };
364
+ if (includeTemperature) { body.temperature = temperature; }
365
+ if (system) { body.system = system; }
366
+ return body;
367
+ }
318
368
 
319
- const result = await postStream(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
369
+ const skipTemperature = modelsWithoutTemperatureSupport.get(settings.model) === true;
370
+ let result;
371
+ try {
372
+ result = await postStream(baseUrl + "/v1/messages", anthropicHeaders(settings), buildBody(!skipTemperature), settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
373
+ } catch (err) {
374
+ if (skipTemperature || !isTemperatureDeprecatedError(err)) { throw err; }
375
+ // Same one-time detection as chat() above. Safe to retry whole-hog:
376
+ // postStream rejects on the response's status line, before any SSE
377
+ // data event is parsed, so onDelta/onReasoningDelta can't have already
378
+ // emitted partial content from the failed attempt.
379
+ modelsWithoutTemperatureSupport.set(settings.model, true);
380
+ result = await postStream(baseUrl + "/v1/messages", anthropicHeaders(settings), buildBody(false), settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
381
+ }
320
382
 
321
383
  return {
322
384
  content: result.content || "",
@@ -362,8 +424,8 @@ async function probeTools(settings) {
362
424
  if (!settings.model) { throw new Error("Model is required."); }
363
425
  const baseUrl = resolveBaseUrl(settings);
364
426
 
365
- try {
366
- const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), {
427
+ function buildProbeBody(includeTemperature) {
428
+ const body = {
367
429
  model: settings.model,
368
430
  messages: [{ role: "user", content: "Call the \"ping\" tool now with no arguments." }],
369
431
  system: "You are being tested for tool/function-calling support.",
@@ -374,9 +436,26 @@ async function probeTools(settings) {
374
436
  }],
375
437
  tool_choice: { type: "auto" },
376
438
  max_tokens: 128,
377
- temperature: 0,
378
439
  stream: false
379
- }, settings.requestTimeoutMs || 30000);
440
+ };
441
+ if (includeTemperature) { body.temperature = 0; }
442
+ return body;
443
+ }
444
+
445
+ const skipTemperature = modelsWithoutTemperatureSupport.get(settings.model) === true;
446
+ try {
447
+ let response;
448
+ try {
449
+ response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), buildProbeBody(!skipTemperature), settings.requestTimeoutMs || 30000);
450
+ } catch (err) {
451
+ // A temperature-deprecated failure here is not "no tool support" —
452
+ // it's an unrelated request-shape error. Retry without temperature
453
+ // before concluding anything about tool support (same detection as
454
+ // chat()/chatStream() above).
455
+ if (skipTemperature || !isTemperatureDeprecatedError(err)) { throw err; }
456
+ modelsWithoutTemperatureSupport.set(settings.model, true);
457
+ response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), buildProbeBody(false), settings.requestTimeoutMs || 30000);
458
+ }
380
459
 
381
460
  const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
382
461
  const hasToolUse = contentArray.some(function (b) { return b.type === "tool_use" && b.name === "ping"; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manny-est/node-red-flowpilot",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "FlowPilot for Node-RED - an AI-powered development assistant sidebar",
5
5
  "main": "flowpilot.js",
6
6
  "keywords": [