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.
- flowforge/__init__.py +0 -0
- flowforge/_classic.html +289 -0
- flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
- flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
- flowforge/_dashboard/favicon.svg +1 -0
- flowforge/_dashboard/index.html +16 -0
- flowforge/_example/trip-planner/.env.example +3 -0
- flowforge/_example/trip-planner/.gitignore +2 -0
- flowforge/_example/trip-planner/.mcp.json +20 -0
- flowforge/_example/trip-planner/README.md +48 -0
- flowforge/_example/trip-planner/config.py +27 -0
- flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
- flowforge/_example/trip-planner/planner.py +113 -0
- flowforge/_example/trip-planner/requirements.txt +3 -0
- flowforge/_example/trip-planner/services/__init__.py +0 -0
- flowforge/_example/trip-planner/services/geo.py +49 -0
- flowforge/_example/trip-planner/services/llm.py +18 -0
- flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
- flowforge/_example/trip-planner/services/weather.py +25 -0
- flowforge/_example/trip-planner/tools/packing_list.py +24 -0
- flowforge/_example/trip-planner/trips/.gitkeep +0 -0
- flowforge/_workflows/diamond_mock.json +71 -0
- flowforge/_workflows/stripe_to_razorpay.json +38 -0
- flowforge/agent/__init__.py +5 -0
- flowforge/agent/server.py +333 -0
- flowforge/api/__init__.py +0 -0
- flowforge/api/agent.py +79 -0
- flowforge/api/checks.py +255 -0
- flowforge/api/connectors.py +289 -0
- flowforge/api/flowmap.py +174 -0
- flowforge/api/runs.py +284 -0
- flowforge/api/scan.py +117 -0
- flowforge/api/security.py +31 -0
- flowforge/api/stats.py +80 -0
- flowforge/checks/__init__.py +13 -0
- flowforge/checks/models.py +41 -0
- flowforge/checks/probes.py +359 -0
- flowforge/cli.py +108 -0
- flowforge/connectors/__init__.py +0 -0
- flowforge/connectors/catalog.py +84 -0
- flowforge/connectors/models.py +149 -0
- flowforge/connectors/presets.py +192 -0
- flowforge/connectors/registry.py +85 -0
- flowforge/connectors/runtime.py +47 -0
- flowforge/connectors/secrets.py +53 -0
- flowforge/example_data.py +247 -0
- flowforge/flow/__init__.py +328 -0
- flowforge/flow/advice.py +132 -0
- flowforge/flow/curly.py +884 -0
- flowforge/flow/index.py +116 -0
- flowforge/flow/order.py +181 -0
- flowforge/flow/reach.py +145 -0
- flowforge/flow/rewrite.py +146 -0
- flowforge/flowmap/__init__.py +1 -0
- flowforge/flowmap/diff.py +145 -0
- flowforge/flowmap/fixture.py +283 -0
- flowforge/flowmap/models.py +258 -0
- flowforge/flowmap/planner.py +131 -0
- flowforge/flowmap/service.py +379 -0
- flowforge/flowmap/validate.py +228 -0
- flowforge/home.py +58 -0
- flowforge/main.py +137 -0
- flowforge/nodes/__init__.py +3 -0
- flowforge/nodes/base.py +74 -0
- flowforge/nodes/check_node.py +50 -0
- flowforge/nodes/http_node.py +161 -0
- flowforge/nodes/llm_node.py +158 -0
- flowforge/nodes/local_node.py +164 -0
- flowforge/nodes/mcp_node.py +243 -0
- flowforge/nodes/mock_node.py +40 -0
- flowforge/scan/__init__.py +6 -0
- flowforge/scan/facts.py +47 -0
- flowforge/scan/files.py +112 -0
- flowforge/scan/manifests.py +65 -0
- flowforge/scan/mcp_config.py +75 -0
- flowforge/scan/models.py +73 -0
- flowforge/scan/patterns.py +86 -0
- flowforge/scan/python_ast.py +246 -0
- flowforge/scan/scanner.py +344 -0
- flowforge/scan/services.py +58 -0
- flowforge/scan/urls.py +60 -0
- flowforge/scheduler/__init__.py +0 -0
- flowforge/scheduler/cache.py +76 -0
- flowforge/scheduler/critical_path.py +65 -0
- flowforge/scheduler/durations.py +35 -0
- flowforge/scheduler/executor.py +390 -0
- flowforge/scheduler/graph.py +108 -0
- flowforge/scheduler/rate_limit.py +36 -0
- flowforge/schema.py +81 -0
- flowforge/security/__init__.py +0 -0
- flowforge/security/finders.py +127 -0
- flowforge/security/findings.py +169 -0
- flowforge/security/logfilter.py +129 -0
- flowforge/spa.py +52 -0
- flowforge/state.py +73 -0
- flowforge/storage.py +481 -0
- flowforge/templating.py +60 -0
- flowforge_map-0.1.0.dist-info/METADATA +335 -0
- flowforge_map-0.1.0.dist-info/RECORD +113 -0
- flowforge_map-0.1.0.dist-info/WHEEL +4 -0
- flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|
flowforge/nodes/base.py
ADDED
|
@@ -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
|