@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 +60 -0
- package/flowpilot.js +81 -42
- package/lib/core/main.js +19 -3
- package/lib/core/modes.js +49 -12
- package/lib/provider-anthropic.js +107 -28
- package/package.json +1 -1
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
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
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
|
|
714
|
-
|
|
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 (
|
|
785
|
-
|
|
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
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1667
|
-
//
|
|
1668
|
-
//
|
|
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
|
|
1675
|
-
var lines = planBlock.split("\n");
|
|
1702
|
+
var lines = afterPlan.split("\n");
|
|
1676
1703
|
var items = [];
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
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
|
-
|
|
366
|
-
const
|
|
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
|
-
}
|
|
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"; });
|