flowforge-map 0.1.0__py3-none-any.whl

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.
Files changed (113) hide show
  1. flowforge/__init__.py +0 -0
  2. flowforge/_classic.html +289 -0
  3. flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  4. flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  5. flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  6. flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  7. flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  8. flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  9. flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  10. flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  11. flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  12. flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  13. flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  14. flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
  15. flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
  16. flowforge/_dashboard/favicon.svg +1 -0
  17. flowforge/_dashboard/index.html +16 -0
  18. flowforge/_example/trip-planner/.env.example +3 -0
  19. flowforge/_example/trip-planner/.gitignore +2 -0
  20. flowforge/_example/trip-planner/.mcp.json +20 -0
  21. flowforge/_example/trip-planner/README.md +48 -0
  22. flowforge/_example/trip-planner/config.py +27 -0
  23. flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
  24. flowforge/_example/trip-planner/planner.py +113 -0
  25. flowforge/_example/trip-planner/requirements.txt +3 -0
  26. flowforge/_example/trip-planner/services/__init__.py +0 -0
  27. flowforge/_example/trip-planner/services/geo.py +49 -0
  28. flowforge/_example/trip-planner/services/llm.py +18 -0
  29. flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
  30. flowforge/_example/trip-planner/services/weather.py +25 -0
  31. flowforge/_example/trip-planner/tools/packing_list.py +24 -0
  32. flowforge/_example/trip-planner/trips/.gitkeep +0 -0
  33. flowforge/_workflows/diamond_mock.json +71 -0
  34. flowforge/_workflows/stripe_to_razorpay.json +38 -0
  35. flowforge/agent/__init__.py +5 -0
  36. flowforge/agent/server.py +333 -0
  37. flowforge/api/__init__.py +0 -0
  38. flowforge/api/agent.py +79 -0
  39. flowforge/api/checks.py +255 -0
  40. flowforge/api/connectors.py +289 -0
  41. flowforge/api/flowmap.py +174 -0
  42. flowforge/api/runs.py +284 -0
  43. flowforge/api/scan.py +117 -0
  44. flowforge/api/security.py +31 -0
  45. flowforge/api/stats.py +80 -0
  46. flowforge/checks/__init__.py +13 -0
  47. flowforge/checks/models.py +41 -0
  48. flowforge/checks/probes.py +359 -0
  49. flowforge/cli.py +108 -0
  50. flowforge/connectors/__init__.py +0 -0
  51. flowforge/connectors/catalog.py +84 -0
  52. flowforge/connectors/models.py +149 -0
  53. flowforge/connectors/presets.py +192 -0
  54. flowforge/connectors/registry.py +85 -0
  55. flowforge/connectors/runtime.py +47 -0
  56. flowforge/connectors/secrets.py +53 -0
  57. flowforge/example_data.py +247 -0
  58. flowforge/flow/__init__.py +328 -0
  59. flowforge/flow/advice.py +132 -0
  60. flowforge/flow/curly.py +884 -0
  61. flowforge/flow/index.py +116 -0
  62. flowforge/flow/order.py +181 -0
  63. flowforge/flow/reach.py +145 -0
  64. flowforge/flow/rewrite.py +146 -0
  65. flowforge/flowmap/__init__.py +1 -0
  66. flowforge/flowmap/diff.py +145 -0
  67. flowforge/flowmap/fixture.py +283 -0
  68. flowforge/flowmap/models.py +258 -0
  69. flowforge/flowmap/planner.py +131 -0
  70. flowforge/flowmap/service.py +379 -0
  71. flowforge/flowmap/validate.py +228 -0
  72. flowforge/home.py +58 -0
  73. flowforge/main.py +137 -0
  74. flowforge/nodes/__init__.py +3 -0
  75. flowforge/nodes/base.py +74 -0
  76. flowforge/nodes/check_node.py +50 -0
  77. flowforge/nodes/http_node.py +161 -0
  78. flowforge/nodes/llm_node.py +158 -0
  79. flowforge/nodes/local_node.py +164 -0
  80. flowforge/nodes/mcp_node.py +243 -0
  81. flowforge/nodes/mock_node.py +40 -0
  82. flowforge/scan/__init__.py +6 -0
  83. flowforge/scan/facts.py +47 -0
  84. flowforge/scan/files.py +112 -0
  85. flowforge/scan/manifests.py +65 -0
  86. flowforge/scan/mcp_config.py +75 -0
  87. flowforge/scan/models.py +73 -0
  88. flowforge/scan/patterns.py +86 -0
  89. flowforge/scan/python_ast.py +246 -0
  90. flowforge/scan/scanner.py +344 -0
  91. flowforge/scan/services.py +58 -0
  92. flowforge/scan/urls.py +60 -0
  93. flowforge/scheduler/__init__.py +0 -0
  94. flowforge/scheduler/cache.py +76 -0
  95. flowforge/scheduler/critical_path.py +65 -0
  96. flowforge/scheduler/durations.py +35 -0
  97. flowforge/scheduler/executor.py +390 -0
  98. flowforge/scheduler/graph.py +108 -0
  99. flowforge/scheduler/rate_limit.py +36 -0
  100. flowforge/schema.py +81 -0
  101. flowforge/security/__init__.py +0 -0
  102. flowforge/security/finders.py +127 -0
  103. flowforge/security/findings.py +169 -0
  104. flowforge/security/logfilter.py +129 -0
  105. flowforge/spa.py +52 -0
  106. flowforge/state.py +73 -0
  107. flowforge/storage.py +481 -0
  108. flowforge/templating.py +60 -0
  109. flowforge_map-0.1.0.dist-info/METADATA +335 -0
  110. flowforge_map-0.1.0.dist-info/RECORD +113 -0
  111. flowforge_map-0.1.0.dist-info/WHEEL +4 -0
  112. flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
  113. flowforge_map-0.1.0.dist-info/licenses/LICENSE +21 -0
flowforge/main.py ADDED
@@ -0,0 +1,137 @@
1
+ """FastAPI app: start-up wiring and small routes. Runs: api/runs.py. Connectors: api/connectors.py.
2
+
3
+ GET /workflows → names of the example workflows
4
+ GET /workflows/{name} → one example workflow's JSON
5
+ GET /, /classic, /assets/* → the dashboard and the V1 status page (spa.py)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import os
13
+ from collections.abc import AsyncIterator
14
+ from contextlib import asynccontextmanager
15
+ from typing import Any
16
+
17
+ from dotenv import load_dotenv
18
+ from fastapi import FastAPI, HTTPException
19
+
20
+ from flowforge import example_data, spa
21
+ from flowforge import state as app_state_module
22
+ from flowforge.api import agent as agent_api
23
+ from flowforge.api import checks as checks_api
24
+ from flowforge.api import connectors as connectors_api
25
+ from flowforge.api import flowmap as flowmap_api
26
+ from flowforge.api import runs as runs_api
27
+ from flowforge.api import scan as scan_api
28
+ from flowforge.api import security as security_api
29
+ from flowforge.api import stats as stats_api
30
+ from flowforge.connectors.models import Connector
31
+ from flowforge.connectors.registry import Registry, check_references, load_connectors_file
32
+ from flowforge.connectors.runtime import build_nodes # noqa: F401 (re-exported for callers)
33
+ from flowforge.flowmap.planner import CodeFlowPlanner, ScanPlanner
34
+ from flowforge.flowmap.service import FlowService
35
+ from flowforge.home import database_path, flowforge_home, resource
36
+ from flowforge.security.logfilter import Redactor, install_log_redaction, secret_values_for
37
+ from flowforge.state import AppState, app_state
38
+ from flowforge.storage import Storage
39
+
40
+ load_dotenv()
41
+
42
+ WORKFLOWS_DIR = resource("workflows") # inside the package when installed (D34)
43
+ MCP_CHECK_TIMEOUT_S = runs_api.MCP_CHECK_TIMEOUT_S
44
+
45
+
46
+ def __getattr__(name: str) -> Any:
47
+ """`main.state` stays readable for callers and tests written before api/ existed."""
48
+ if name == "state":
49
+ return app_state_module.current
50
+ raise AttributeError(name)
51
+
52
+
53
+ @asynccontextmanager
54
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
55
+ example = os.getenv("FLOWFORGE_EXAMPLE") == "1"
56
+ redactor = Redactor()
57
+ if example:
58
+ flowforge_home().mkdir(parents=True, exist_ok=True)
59
+ storage = Storage(database_path(example), redact=redactor.redact)
60
+ registry = Registry(storage)
61
+ registry.ensure_defaults()
62
+ from_file = load_connectors_file(flowforge_home() / "connectors.json")
63
+ for connector in from_file:
64
+ registry.save(connector)
65
+ connectors = registry.all()
66
+ check_references(connectors)
67
+ redactor.set_values(secret_values_for(connectors))
68
+ restore_logging = install_log_redaction(redactor)
69
+ storage.mark_interrupted()
70
+ nodes, buckets = build_nodes(connectors)
71
+ s = app_state_module.current = AppState(
72
+ nodes=nodes, rate_limits=buckets, storage=storage, registry=registry, redactor=redactor,
73
+ file_managed={c.id for c in from_file}, example=example)
74
+ # the maps (D17, D21, D25): what the repo depends on, and the order its code calls them. Both come from code
75
+ # (D23: nothing drawn by hand reaches a map).
76
+ def connected() -> dict[str, Connector]:
77
+ return {c.id: c for c in registry.all()}
78
+
79
+ drawn = storage.flows("flow").latest_flow_version("accepted")
80
+ if example and drawn and (drawn["flow"] or {}).get("source") != "code":
81
+ storage.clear_map("flow") # an example.db from before D25 still holds a hand-drawn flow
82
+ s.flows = {"deps": FlowService(storage.flows("deps"), connected, None),
83
+ "flow": FlowService(storage.flows("flow"), connected, None)}
84
+ if example:
85
+ s.flows["deps"].planner = ScanPlanner(scan_api.EXAMPLE_REPO)
86
+ s.flows["flow"].planner = CodeFlowPlanner(scan_api.EXAMPLE_REPO, lambda: example_data.latencies(storage))
87
+ else: # your folder: from --repo or the last one chosen, mapped now if it has no maps yet
88
+ scan_api.startup_repo(storage, s.flows)
89
+ if example: # offline nodes and a seeded history, never mixed with real data (D16)
90
+ example_data.install(s)
91
+ example_data.seed_flow(s)
92
+ seeding = asyncio.create_task(example_data.seed(s))
93
+ s.tasks.add(seeding)
94
+ seeding.add_done_callback(s.tasks.discard)
95
+ try:
96
+ yield
97
+ finally:
98
+ s.shutting_down = True
99
+ for task in list(s.tasks):
100
+ task.cancel()
101
+ if s.tasks:
102
+ await asyncio.gather(*s.tasks, return_exceptions=True)
103
+ for node in {id(n): n for n in [*s.nodes.values(), *s.retired_nodes]}.values():
104
+ await node.aclose()
105
+ restore_logging()
106
+
107
+
108
+ app = FastAPI(title="FlowForge", lifespan=lifespan)
109
+ app.include_router(runs_api.router)
110
+ app.include_router(connectors_api.router)
111
+ app.include_router(stats_api.router)
112
+ app.include_router(flowmap_api.router)
113
+ app.include_router(scan_api.router)
114
+ app.include_router(checks_api.router)
115
+ app.include_router(agent_api.router)
116
+ app.include_router(security_api.router)
117
+
118
+
119
+ @app.get("/workflows")
120
+ def list_workflows() -> list[str]:
121
+ names = {p.stem for p in WORKFLOWS_DIR.glob("*.json")}
122
+ if app_state().example:
123
+ names |= set(example_data.workflows())
124
+ return sorted(names)
125
+
126
+
127
+ @app.get("/workflows/{name}")
128
+ def get_workflow(name: str) -> dict[str, Any]:
129
+ if app_state().example and name in example_data.workflows():
130
+ return example_data.workflows()[name]
131
+ path = WORKFLOWS_DIR / f"{name}.json"
132
+ if path.parent != WORKFLOWS_DIR or not path.exists():
133
+ raise HTTPException(404, "unknown workflow")
134
+ return json.loads(path.read_text(encoding="utf-8"))
135
+
136
+
137
+ spa.install(app) # last: the built dashboard, /classic and the HTML-vs-JSON routing (D15)
@@ -0,0 +1,3 @@
1
+ from flowforge.nodes.base import Node, NodeError, TransientNodeError
2
+
3
+ __all__ = ["Node", "NodeError", "TransientNodeError"]
@@ -0,0 +1,74 @@
1
+ """The interface every step type implements, plus the error split the executor retries on (D7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from contextvars import ContextVar
7
+ from datetime import UTC, datetime
8
+ from email.utils import parsedate_to_datetime
9
+ from typing import Any
10
+
11
+ # The run a node call belongs to, for naming artifact folders (D12). Set by the API before
12
+ # run_workflow; step tasks inherit it. None outside a run.
13
+ current_run_id: ContextVar[str | None] = ContextVar("current_run_id", default=None)
14
+
15
+
16
+ class NodeError(Exception):
17
+ """Permanent failure — do not retry (bad request, auth error, ...)."""
18
+
19
+
20
+ class TransientNodeError(NodeError):
21
+ """Retryable failure: 429, 5xx, timeout, connection error.
22
+
23
+ `retry_after_s` carries a server-provided Retry-After hint when there is one.
24
+ """
25
+
26
+ def __init__(self, message: str, retry_after_s: float | None = None):
27
+ super().__init__(message)
28
+ self.retry_after_s = retry_after_s
29
+
30
+
31
+ class Node(ABC):
32
+ type: str
33
+ # Name of the rate-limit bucket this node draws from (None = unlimited). Per instance
34
+ # when built from a connector (D10).
35
+ rate_limit_key: str | None = None
36
+ # Set when built from a connector (D10). Nodes built without one keep V1 behaviour.
37
+ connector_id: str | None = None
38
+ fallback: str | None = None # connector id tried after transient exhaustion (D10)
39
+ default_for_type: bool = False # the type's default connector keeps the V1 cache namespace
40
+
41
+ @property
42
+ def cache_namespace(self) -> str:
43
+ """Prefix of this node's cache keys: two connectors never share an answer (D10)."""
44
+ if self.connector_id is None or self.default_for_type:
45
+ return self.type
46
+ return f"{self.type}:{self.connector_id}"
47
+
48
+ @abstractmethod
49
+ async def run(self, params: dict[str, Any]) -> Any:
50
+ """Execute with fully resolved params and return a JSON-serialisable output."""
51
+
52
+ def cacheable_across_runs(self, params: dict[str, Any]) -> bool:
53
+ """Per-type default for the persistent cache (D3). Overridden by step.cache."""
54
+ return False
55
+
56
+ async def aclose(self) -> None: # noqa: B027 — optional hook; most nodes hold nothing
57
+ """Release connections/processes held across calls."""
58
+
59
+
60
+ def parse_retry_after(value: str | None) -> float | None:
61
+ """Retry-After is either delta-seconds or an HTTP date."""
62
+ if not value:
63
+ return None
64
+ try:
65
+ return max(0.0, float(value))
66
+ except ValueError:
67
+ pass
68
+ try:
69
+ when = parsedate_to_datetime(value)
70
+ except (TypeError, ValueError):
71
+ return None
72
+ if when.tzinfo is None:
73
+ when = when.replace(tzinfo=UTC)
74
+ return max(0.0, (when - datetime.now(UTC)).total_seconds())
@@ -0,0 +1,50 @@
1
+ """The `check` step (D24): one health check per step, so the scheduler runs a check run like any other."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from flowforge.checks import CheckTarget, check, new_client
12
+ from flowforge.nodes.base import Node, NodeError
13
+
14
+ # MCP servers are processes; at most this many start together (D24). The scheduler's slots are per step type and
15
+ # every check is one type, so the limit lives here; check_workflow() gives MCP steps a timeout that covers the wait.
16
+ MCP_AT_ONCE = 3
17
+
18
+
19
+ class CheckNode(Node):
20
+ type = "check"
21
+
22
+ def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None:
23
+ self._transport = transport # tests answer through a MockTransport
24
+ self._http: httpx.AsyncClient | None = None
25
+ self._mcp = asyncio.Semaphore(MCP_AT_ONCE)
26
+
27
+ def _client(self) -> httpx.AsyncClient:
28
+ if self._http is None:
29
+ self._http = new_client(self._transport)
30
+ return self._http
31
+
32
+ async def run(self, params: dict[str, Any]) -> Any:
33
+ """params: `target` (a CheckTarget) and `root` (the scanned folder). A dependency that is down is a
34
+ result; only a check that can't run at all fails the step."""
35
+ try:
36
+ target = CheckTarget.model_validate(params["target"])
37
+ root = Path(params["root"])
38
+ except (KeyError, ValueError) as exc:
39
+ raise NodeError(f"check step: {exc}") from exc
40
+ if target.kind == "mcp":
41
+ async with self._mcp:
42
+ result = await check(target, root, http=self._client())
43
+ else:
44
+ result = await check(target, root, http=self._client())
45
+ return {**result.model_dump(mode="json"), "kind": target.kind}
46
+
47
+ async def aclose(self) -> None:
48
+ if self._http is not None:
49
+ await self._http.aclose()
50
+ self._http = None
@@ -0,0 +1,161 @@
1
+ """Plain HTTP call via httpx (D9).
2
+
3
+ params: method (default GET), url, headers?, json?, max_chars? (default 20000),
4
+ extract_text? (default: true for HTML responses — strips tags/scripts so an LLM
5
+ step downstream gets the readable text, not markup)
6
+ output: {"status": int, "body": str | parsed JSON}
7
+
8
+ With a connector (D10): a relative params.url is joined to the connector's base_url, and
9
+ the auth header is built from its secret_ref (bearer / basic / raw). The key is only ever
10
+ sent to the base_url's origin: an absolute URL elsewhere is refused, step headers cannot
11
+ replace the auth header, and the key is redacted from error messages.
12
+
13
+ 429 / 5xx / timeouts / connection errors → TransientNodeError (honours Retry-After);
14
+ other 4xx → NodeError.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import re
21
+ from html.parser import HTMLParser
22
+ from typing import Any
23
+
24
+ import httpx
25
+
26
+ from flowforge.connectors.models import HTTPConnector
27
+ from flowforge.connectors.secrets import SecretRefError, resolve_secret
28
+ from flowforge.nodes.base import Node, NodeError, TransientNodeError, parse_retry_after
29
+
30
+ USER_AGENT = "FlowForge/0.1 (+https://github.com/Vishwa-Thangapandiyan/flowforge-agent-orchestrator)"
31
+
32
+
33
+ class _TextExtractor(HTMLParser):
34
+ SKIP = {"script", "style", "noscript", "svg", "head", "template"}
35
+ BLOCK = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "section", "article", "pre", "table"}
36
+
37
+ def __init__(self) -> None:
38
+ super().__init__(convert_charrefs=True)
39
+ self.parts: list[str] = []
40
+ self._skipping = 0
41
+
42
+ def handle_starttag(self, tag: str, attrs: Any) -> None:
43
+ if tag in self.SKIP:
44
+ self._skipping += 1
45
+ elif tag in self.BLOCK:
46
+ self.parts.append("\n")
47
+
48
+ def handle_endtag(self, tag: str) -> None:
49
+ if tag in self.SKIP and self._skipping:
50
+ self._skipping -= 1
51
+
52
+ def handle_data(self, data: str) -> None:
53
+ if not self._skipping:
54
+ self.parts.append(data)
55
+
56
+
57
+ def html_to_text(html: str) -> str:
58
+ parser = _TextExtractor()
59
+ parser.feed(html)
60
+ text = "".join(parser.parts)
61
+ text = re.sub(r"[ \t\r\f\v]+", " ", text)
62
+ return re.sub(r"\n\s*\n+", "\n\n", text).strip()
63
+
64
+
65
+ class HTTPNode(Node):
66
+ type = "http"
67
+
68
+ def __init__(self, client: httpx.AsyncClient | None = None) -> None:
69
+ self._client = client
70
+ self.connector: HTTPConnector | None = None
71
+
72
+ @classmethod
73
+ def from_connector(cls, connector: HTTPConnector, client: httpx.AsyncClient | None = None) -> HTTPNode:
74
+ node = cls(client)
75
+ node.connector = connector
76
+ node.connector_id = connector.id
77
+ node.rate_limit_key = connector.id
78
+ node.fallback = connector.fallback
79
+ return node
80
+
81
+ @property
82
+ def client(self) -> httpx.AsyncClient:
83
+ if self._client is None:
84
+ self._client = httpx.AsyncClient(follow_redirects=True, headers={"User-Agent": USER_AGENT})
85
+ return self._client
86
+
87
+ async def aclose(self) -> None:
88
+ if self._client is not None:
89
+ await self._client.aclose()
90
+
91
+ def cacheable_across_runs(self, params: dict[str, Any]) -> bool:
92
+ return params.get("method", "GET").upper() == "GET"
93
+
94
+ def _prepare(self, params: dict[str, Any]) -> tuple[str, dict[str, str] | None, list[str]]:
95
+ """(url, headers, secret strings to redact) for this call."""
96
+ url, headers = params["url"], params.get("headers")
97
+ if self.connector is None:
98
+ return url, headers, []
99
+ conn = self.connector.connection
100
+ label = f"http connector '{self.connector.id}'"
101
+ absolute = "://" in url or url.startswith("//")
102
+ if not absolute:
103
+ if not conn.base_url:
104
+ raise NodeError(f"{label}: relative url '{url}' needs a base_url on the connector")
105
+ url = conn.base_url.rstrip("/") + "/" + url.lstrip("/")
106
+ try:
107
+ secret = resolve_secret(self.connector.secret_ref)
108
+ except SecretRefError as exc:
109
+ raise NodeError(f"{label}: {exc}") from exc
110
+ if secret is None:
111
+ return url, headers, []
112
+ if not conn.base_url:
113
+ raise NodeError(f"{label}: a connector with a secret needs base_url, the only place its key is sent")
114
+ base = httpx.URL(conn.base_url)
115
+ target = httpx.URL("https:" + url if url.startswith("//") else url)
116
+ if (target.scheme, target.host, target.port) != (base.scheme, base.host, base.port):
117
+ raise NodeError(f"{label} only sends its key to {base.scheme}://{base.host}; refusing {target.host}")
118
+ header = conn.auth_header or "Authorization"
119
+ scheme = conn.auth_scheme or "bearer"
120
+ value = {"bearer": f"Bearer {secret}",
121
+ "basic": "Basic " + base64.b64encode(secret.encode()).decode(),
122
+ "raw": secret}[scheme]
123
+ merged = {k: v for k, v in (headers or {}).items() if k.lower() != header.lower()}
124
+ merged[header] = value
125
+ return url, merged, [secret, value]
126
+
127
+ async def run(self, params: dict[str, Any]) -> Any:
128
+ if "url" not in params:
129
+ raise NodeError("http step needs params.url")
130
+ method = params.get("method", "GET").upper()
131
+ url, headers, secrets = self._prepare(params)
132
+
133
+ def redact(text: str) -> str:
134
+ for secret in secrets:
135
+ text = text.replace(secret, "[REDACTED]")
136
+ return text
137
+
138
+ try:
139
+ response = await self.client.request(method, url, headers=headers, json=params.get("json"))
140
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
141
+ raise TransientNodeError(redact(f"{type(exc).__name__}: {exc}")) from exc
142
+
143
+ status = response.status_code
144
+ if status == 429 or status >= 500:
145
+ raise TransientNodeError(
146
+ f"HTTP {status} from {params['url']}", parse_retry_after(response.headers.get("retry-after"))
147
+ )
148
+ if status >= 400:
149
+ # redact before cutting, so a key straddling the cut can't leak its first half
150
+ raise NodeError(f"HTTP {status} from {params['url']}: {redact(response.text)[:200]}")
151
+
152
+ content_type = response.headers.get("content-type", "")
153
+ if "json" in content_type:
154
+ try:
155
+ return {"status": status, "body": response.json()}
156
+ except ValueError:
157
+ pass
158
+ body = response.text
159
+ if params.get("extract_text", "html" in content_type):
160
+ body = html_to_text(body)
161
+ return {"status": status, "body": body[: params.get("max_chars", 20000)]}
@@ -0,0 +1,158 @@
1
+ """LLM chat completion for any provider (D8, D10).
2
+
3
+ params: prompt (str) | messages (list), model?, temperature?, max_tokens?
4
+ output: {"text": str, "model": str, "usage": {"prompt_tokens", "completion_tokens"[, "credits"]}}
5
+
6
+ `LLMNode()` is the V1 node: NVIDIA NIM from env (D8), output unchanged.
7
+ `LLMNode.from_connector(c)` builds a node for one connector (D10):
8
+ openai_compatible — NIM, Gemini's OpenAI endpoint, Ollama, vLLM, OpenRouter (openai SDK)
9
+ anthropic — Claude via the official anthropic SDK; `system` messages move to the
10
+ `system` parameter, sampling parameters are not sent (current Claude
11
+ models reject them), and server-side refusal fallback is enabled on
12
+ the models that support it
13
+ Connector nodes add usage.credits (0 until a provider reports credits; no invented prices).
14
+
15
+ 429 / 5xx / timeouts / connection errors → TransientNodeError (with Retry-After);
16
+ everything else (bad request, auth, refusal) → NodeError. The SDKs' own retries are off —
17
+ the executor owns retries so they go through the rate limiter.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from typing import Any
24
+
25
+ import anthropic
26
+ import openai
27
+
28
+ from flowforge.connectors.models import LLMConnector
29
+ from flowforge.connectors.secrets import SecretRefError, resolve_secret
30
+ from flowforge.nodes.base import Node, NodeError, TransientNodeError, parse_retry_after
31
+
32
+ ANTHROPIC_DEFAULT_MAX_TOKENS = 16000 # thinking is always on; a small budget truncates answers
33
+ # Models that accept the server-side refusal fallback ("default" routing by refusal category).
34
+ ANTHROPIC_SERVER_FALLBACK_MODELS = {"claude-fable-5-1", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5"}
35
+ ANTHROPIC_FALLBACK_BETA = "server-side-fallback-2026-07-01"
36
+
37
+
38
+ class LLMNode(Node):
39
+ type = "llm"
40
+ rate_limit_key = "nim"
41
+
42
+ def __init__(self, client: Any | None = None) -> None:
43
+ """The V1 default: NVIDIA NIM configured from env (D8)."""
44
+ self.provider = "openai_compatible"
45
+ self.label = "NIM"
46
+ self.base_url: str | None = os.getenv("NIM_BASE_URL", "https://integrate.api.nvidia.com/v1")
47
+ self.default_model = os.getenv("NIM_DEFAULT_MODEL", "meta/llama-3.1-8b-instruct")
48
+ self.temperature_default = 0.0
49
+ self.secret_ref: str | None = None # None here = the V1 NVIDIA_API_KEY lookup
50
+ self.report_credits = False
51
+ self._client = client
52
+
53
+ @classmethod
54
+ def from_connector(cls, connector: LLMConnector, client: Any | None = None) -> LLMNode:
55
+ node = cls(client)
56
+ conn = connector.connection
57
+ node.connector_id = connector.id
58
+ node.rate_limit_key = connector.id
59
+ node.fallback = connector.fallback
60
+ node.provider = conn.provider
61
+ node.label = f"{connector.id} ({conn.provider})"
62
+ node.base_url = conn.base_url
63
+ node.default_model = conn.model
64
+ node.temperature_default = conn.temperature_default
65
+ node.secret_ref = connector.secret_ref
66
+ node.report_credits = True
67
+ return node
68
+
69
+ @property
70
+ def client(self) -> Any:
71
+ if self._client is None:
72
+ self._client = self._make_client()
73
+ return self._client
74
+
75
+ def _make_client(self) -> Any:
76
+ if self.connector_id is None:
77
+ api_key = os.getenv("NVIDIA_API_KEY")
78
+ if not api_key:
79
+ raise NodeError("NVIDIA_API_KEY is not set — get a free key at build.nvidia.com and put it in .env")
80
+ return openai.AsyncOpenAI(api_key=api_key, base_url=self.base_url, max_retries=0)
81
+ try:
82
+ api_key = resolve_secret(self.secret_ref)
83
+ except SecretRefError as exc:
84
+ raise NodeError(f"{self.label}: {exc}") from exc
85
+ if self.provider == "anthropic":
86
+ # no key configured → the SDK resolves its own credentials (env, `ant auth login` profile)
87
+ return anthropic.AsyncAnthropic(**({"api_key": api_key} if api_key else {}), max_retries=0)
88
+ # keyless local servers (Ollama) still need a non-empty value for the client
89
+ return openai.AsyncOpenAI(api_key=api_key or "unused", base_url=self.base_url, max_retries=0)
90
+
91
+ def cacheable_across_runs(self, params: dict[str, Any]) -> bool:
92
+ return params.get("temperature", self.temperature_default) == 0
93
+
94
+ async def run(self, params: dict[str, Any]) -> Any:
95
+ if "messages" in params:
96
+ messages = params["messages"]
97
+ elif "prompt" in params:
98
+ messages = [{"role": "user", "content": str(params["prompt"])}]
99
+ else:
100
+ raise NodeError("llm step needs params.prompt or params.messages")
101
+
102
+ sdk = anthropic if self.provider == "anthropic" else openai
103
+ try:
104
+ if self.provider == "anthropic":
105
+ text, model, prompt_tokens, completion_tokens = await self._anthropic(params, messages)
106
+ else:
107
+ text, model, prompt_tokens, completion_tokens = await self._openai(params, messages)
108
+ except sdk.RateLimitError as exc:
109
+ raise TransientNodeError(
110
+ f"{self.label} rate limit: {exc}", parse_retry_after(exc.response.headers.get("retry-after"))
111
+ ) from exc
112
+ except sdk.APIStatusError as exc:
113
+ if exc.status_code >= 500:
114
+ raise TransientNodeError(
115
+ f"{self.label} {exc.status_code}: {exc}", parse_retry_after(exc.response.headers.get("retry-after"))
116
+ ) from exc
117
+ raise NodeError(f"{self.label} {exc.status_code}: {exc}") from exc
118
+ except sdk.APIConnectionError as exc: # includes APITimeoutError
119
+ raise TransientNodeError(f"{self.label} connection: {exc}") from exc
120
+
121
+ usage: dict[str, Any] = {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens}
122
+ if self.report_credits:
123
+ usage["credits"] = 0
124
+ return {"text": text, "model": model, "usage": usage}
125
+
126
+ async def _openai(self, params: dict[str, Any], messages: list[Any]) -> tuple[str, str, int, int]:
127
+ completion = await self.client.chat.completions.create(
128
+ model=params.get("model", self.default_model),
129
+ messages=messages,
130
+ temperature=params.get("temperature", self.temperature_default),
131
+ max_tokens=params.get("max_tokens", 512),
132
+ )
133
+ usage = completion.usage
134
+ return (
135
+ completion.choices[0].message.content or "",
136
+ completion.model,
137
+ usage.prompt_tokens if usage else 0,
138
+ usage.completion_tokens if usage else 0,
139
+ )
140
+
141
+ async def _anthropic(self, params: dict[str, Any], messages: list[Any]) -> tuple[str, str, int, int]:
142
+ system = "\n\n".join(str(m["content"]) for m in messages if m.get("role") == "system")
143
+ model = params.get("model", self.default_model)
144
+ request: dict[str, Any] = {
145
+ "model": model,
146
+ "max_tokens": params.get("max_tokens", ANTHROPIC_DEFAULT_MAX_TOKENS),
147
+ "messages": [m for m in messages if m.get("role") != "system"],
148
+ }
149
+ if system:
150
+ request["system"] = system
151
+ if model in ANTHROPIC_SERVER_FALLBACK_MODELS:
152
+ request |= {"betas": [ANTHROPIC_FALLBACK_BETA], "fallbacks": "default"}
153
+ message = await self.client.beta.messages.create(**request)
154
+ if message.stop_reason == "refusal":
155
+ category = getattr(message.stop_details, "category", None) if message.stop_details else None
156
+ raise NodeError(f"{self.label} refused the request" + (f" ({category})" if category else ""))
157
+ text = "".join(block.text for block in message.content if block.type == "text")
158
+ return text, message.model, message.usage.input_tokens, message.usage.output_tokens