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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""The connector catalog (D17): every app FlowForge knows, whether it is on the map, and a mock test run.
|
|
2
|
+
|
|
3
|
+
Brands live only in `presets.py`. Test runs here are offline and canned ("mock"): they show what a
|
|
4
|
+
real test would do and what the Planner learns from an app, and they never touch a network or a key.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from flowforge.connectors.models import Connector
|
|
12
|
+
from flowforge.connectors.presets import CATALOG, CATEGORIES
|
|
13
|
+
|
|
14
|
+
TYPE_NAME = {"llm": "LLM", "mcp": "MCP server", "http": "HTTP API", "local": "Local command"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def catalog(connectors: dict[str, Connector], on_map: set[str]) -> dict[str, Any]:
|
|
18
|
+
"""Catalog entries with a status each, plus connected apps the catalog doesn't list (your own).
|
|
19
|
+
|
|
20
|
+
status: `on_map` (the code calls it), `connected` (connected, not in your code) or `none`.
|
|
21
|
+
"""
|
|
22
|
+
def status(app_id: str) -> str:
|
|
23
|
+
if app_id in on_map:
|
|
24
|
+
return "on_map"
|
|
25
|
+
return "connected" if app_id in connectors else "none"
|
|
26
|
+
|
|
27
|
+
items = [{**entry, "kind": TYPE_NAME[entry["type"]], "status": status(entry["id"])} for entry in CATALOG]
|
|
28
|
+
listed = {entry["id"] for entry in CATALOG}
|
|
29
|
+
own = [{"id": c.id, "name": c.name, "category": "custom", "type": c.type, "kind": TYPE_NAME[c.type],
|
|
30
|
+
"live": True, "description": c.role or "One of your own apps.", "meta": f"{TYPE_NAME[c.type]} · yours",
|
|
31
|
+
"status": status(c.id), "color": c.style.color if c.style else None}
|
|
32
|
+
for c in connectors.values() if c.id not in listed]
|
|
33
|
+
return {"categories": CATEGORIES, "items": items, "own": own}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def find(app_id: str, connectors: dict[str, Connector]) -> dict[str, Any] | None:
|
|
37
|
+
entry = next((e for e in CATALOG if e["id"] == app_id), None)
|
|
38
|
+
if entry is not None:
|
|
39
|
+
return entry
|
|
40
|
+
c = connectors.get(app_id)
|
|
41
|
+
if c is None:
|
|
42
|
+
return None
|
|
43
|
+
return {"id": c.id, "name": c.name, "category": "custom", "type": c.type, "live": True}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def mock_test(entry: dict[str, Any]) -> dict[str, Any]:
|
|
47
|
+
"""The canned steps, result and capability summary for one app. No network, no key, no secret text."""
|
|
48
|
+
kind, category, name = entry["type"], entry["category"], entry["name"]
|
|
49
|
+
if category == "pay":
|
|
50
|
+
steps = [("Connect in test mode", "a live key is refused on a test connector"),
|
|
51
|
+
("Create a test order", "POST /v1/orders · ₹499.00"), ("Read it back", "GET /v1/orders/{id}")]
|
|
52
|
+
result = '{ "id": "order_example", "amount": 49900, "status": "created" }'
|
|
53
|
+
stats = "0.3 s · test mode, no money moves"
|
|
54
|
+
learns = ["create order", "fetch payment", "refund · always behind a gate"]
|
|
55
|
+
elif kind == "llm":
|
|
56
|
+
steps = [("Connect", "chat/completions"), ("Send a tiny prompt", '"Say hello from FlowForge in one line."'),
|
|
57
|
+
("Read the answer", "check the reply and the token count")]
|
|
58
|
+
result, stats = '"Hello from FlowForge. Test run OK."', "0.4 s · 18 tokens"
|
|
59
|
+
learns = ["provider and model", "rate limit", "token counts per call"]
|
|
60
|
+
elif kind == "mcp":
|
|
61
|
+
steps = [("Start the server", "on this machine, as its own process"), ("List its tools", "tools/list"),
|
|
62
|
+
("Check the schemas", "arguments are validated before any run")]
|
|
63
|
+
result, stats = "tools listed with their input schemas", "0.6 s · server stopped after"
|
|
64
|
+
learns = ["every tool and its arguments", "which tools only read", "tools that write wait for you"]
|
|
65
|
+
elif kind == "local":
|
|
66
|
+
steps = [("Check the command", "it exists and isn't a shell script"),
|
|
67
|
+
("Run it in its folder", "no shell · env passed explicitly"),
|
|
68
|
+
("Read the output", "stdout, stderr, exit code")]
|
|
69
|
+
result, stats = "exit 0", "1.2 s"
|
|
70
|
+
learns = ["the command and its arguments", "the folder it may use", "always behind a gate"]
|
|
71
|
+
elif category == "media":
|
|
72
|
+
steps = [("Connect", f"{name} API"), ("Make one sample", '"a soft blue baby blanket"'),
|
|
73
|
+
("Save the file", "kept on disk, not in the run log")]
|
|
74
|
+
result, stats = '{ "file": "artifacts/test/sample.png", "width": 1024 }', "0.5 s · example output"
|
|
75
|
+
learns = ["makes images, audio or video", "returns a file", "cost per call, when it reports one"]
|
|
76
|
+
else:
|
|
77
|
+
steps = [("Connect", "GET {base_url}/health"), ("Read its endpoints", "from OpenAPI when you give it"),
|
|
78
|
+
("One harmless call", "GET only")]
|
|
79
|
+
result, stats = '{ "ok": true }', "0.2 s"
|
|
80
|
+
learns = ["endpoints and their parameters", "which calls write data (gated)", "key name only, never the value"]
|
|
81
|
+
mode = ("Live: works today with your own key or on your machine. This test run is a mock, so nothing is sent."
|
|
82
|
+
if entry.get("live") else "Demo only: example data, no network, no key needed.")
|
|
83
|
+
return {"id": entry["id"], "name": name, "mock": True, "mode": mode,
|
|
84
|
+
"steps": [{"title": t, "text": x} for t, x in steps], "result": result, "stats": stats, "learns": learns}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Connector models (D10; MCP D12; local D13). Shape follows CLAUDE.md §7.1.
|
|
2
|
+
|
|
3
|
+
A connector is configuration: who to call, how to connect, which secret (by reference
|
|
4
|
+
only) and how it looks. Nodes are built from connectors; the executor never sees these.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Annotated, Any, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator
|
|
12
|
+
|
|
13
|
+
from flowforge.connectors.secrets import SECRET_REF_PATTERN, check_ref
|
|
14
|
+
|
|
15
|
+
CONNECTOR_ID_PATTERN = r"^[A-Za-z][A-Za-z0-9_-]{0,63}$"
|
|
16
|
+
ENV_VAR_PATTERN = r"^[A-Za-z_][A-Za-z0-9_]*$"
|
|
17
|
+
# Step types name their default node in the executor's node map (D10), so no connector may use them.
|
|
18
|
+
RESERVED_IDS = frozenset({"llm", "mcp", "http", "local", "mock"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Model(BaseModel):
|
|
22
|
+
model_config = ConfigDict(extra="forbid")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --- style (stored now, rendered from Phase 2) --------------------------------------------------
|
|
26
|
+
|
|
27
|
+
class LettersLogo(_Model):
|
|
28
|
+
type: Literal["letters"] = "letters"
|
|
29
|
+
text: str = Field(max_length=2)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UploadLogo(_Model):
|
|
33
|
+
type: Literal["upload"] = "upload"
|
|
34
|
+
file: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NoLogo(_Model):
|
|
38
|
+
type: Literal["none"] = "none"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Style(_Model):
|
|
42
|
+
color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
|
43
|
+
logo: Annotated[LettersLogo | UploadLogo | NoLogo, Field(discriminator="type")] = NoLogo()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --- connections, one per type ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
class LLMConnection(_Model):
|
|
49
|
+
provider: Literal["openai_compatible", "anthropic"]
|
|
50
|
+
base_url: str | None = None # required for openai_compatible
|
|
51
|
+
model: str
|
|
52
|
+
temperature_default: float = Field(default=0, ge=0, le=2)
|
|
53
|
+
|
|
54
|
+
@model_validator(mode="after")
|
|
55
|
+
def _base_url_for_openai_compatible(self) -> LLMConnection:
|
|
56
|
+
if self.provider == "openai_compatible" and not self.base_url:
|
|
57
|
+
raise ValueError("openai_compatible connections need base_url")
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MCPConnection(_Model):
|
|
62
|
+
command: str
|
|
63
|
+
args: list[str] = Field(default_factory=list)
|
|
64
|
+
env: dict[Annotated[str, Field(pattern=ENV_VAR_PATTERN)], str] = Field(default_factory=dict)
|
|
65
|
+
env_refs: dict[Annotated[str, Field(pattern=ENV_VAR_PATTERN)],
|
|
66
|
+
Annotated[str, Field(pattern=SECRET_REF_PATTERN)]] = Field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
@model_validator(mode="after")
|
|
69
|
+
def _refs_are_names(self) -> MCPConnection:
|
|
70
|
+
_check_env_refs(self.env_refs)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_env_refs(refs: dict[str, str]) -> None:
|
|
75
|
+
for ref in refs.values():
|
|
76
|
+
check_ref(ref)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class HTTPConnection(_Model):
|
|
80
|
+
base_url: str | None = None
|
|
81
|
+
auth_header: str | None = None # header name only, e.g. "Authorization"
|
|
82
|
+
auth_scheme: Literal["bearer", "basic", "raw"] | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LocalConnection(_Model):
|
|
86
|
+
command: list[str] = Field(min_length=1)
|
|
87
|
+
cwd: str
|
|
88
|
+
env: dict[Annotated[str, Field(pattern=ENV_VAR_PATTERN)], str] = Field(default_factory=dict)
|
|
89
|
+
env_refs: dict[Annotated[str, Field(pattern=ENV_VAR_PATTERN)],
|
|
90
|
+
Annotated[str, Field(pattern=SECRET_REF_PATTERN)]] = Field(default_factory=dict)
|
|
91
|
+
max_output_bytes: int = Field(default=1_000_000, gt=0)
|
|
92
|
+
|
|
93
|
+
@model_validator(mode="after")
|
|
94
|
+
def _refs_are_names(self) -> LocalConnection:
|
|
95
|
+
_check_env_refs(self.env_refs)
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --- connectors ---------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
class ConnectorBase(_Model):
|
|
102
|
+
id: str = Field(pattern=CONNECTOR_ID_PATTERN)
|
|
103
|
+
name: str
|
|
104
|
+
role: str = ""
|
|
105
|
+
slot: str | None = None
|
|
106
|
+
style: Style = Field(default_factory=Style)
|
|
107
|
+
secret_ref: str | None = Field(default=None, pattern=SECRET_REF_PATTERN)
|
|
108
|
+
mode: Literal["test", "live"] | None = None
|
|
109
|
+
rate_limit_rpm: float | None = Field(default=None, gt=0)
|
|
110
|
+
data_sent: Literal["all", "redacted_only"] = "all" # behaviour arrives in Phase 4
|
|
111
|
+
fallback: str | None = Field(default=None, pattern=CONNECTOR_ID_PATTERN)
|
|
112
|
+
created_at: str | None = None
|
|
113
|
+
|
|
114
|
+
@model_validator(mode="after")
|
|
115
|
+
def _fallback_not_self(self) -> ConnectorBase:
|
|
116
|
+
check_ref(self.secret_ref)
|
|
117
|
+
if self.id in RESERVED_IDS:
|
|
118
|
+
raise ValueError(f"'{self.id}' is reserved for the {self.id} type's default node; pick another id")
|
|
119
|
+
if self.fallback == self.id:
|
|
120
|
+
raise ValueError(f"connector '{self.id}' cannot fall back to itself")
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class LLMConnector(ConnectorBase):
|
|
125
|
+
type: Literal["llm"]
|
|
126
|
+
connection: LLMConnection
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class MCPConnector(ConnectorBase):
|
|
130
|
+
type: Literal["mcp"]
|
|
131
|
+
connection: MCPConnection
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class HTTPConnector(ConnectorBase):
|
|
135
|
+
type: Literal["http"]
|
|
136
|
+
connection: HTTPConnection = Field(default_factory=HTTPConnection)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class LocalConnector(ConnectorBase):
|
|
140
|
+
type: Literal["local"]
|
|
141
|
+
connection: LocalConnection
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
Connector = Annotated[LLMConnector | MCPConnector | HTTPConnector | LocalConnector, Field(discriminator="type")]
|
|
145
|
+
_ADAPTER: TypeAdapter[Connector] = TypeAdapter(Connector)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_connector(data: Any) -> Connector:
|
|
149
|
+
return _ADAPTER.validate_python(data)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Preset connectors (CLAUDE.md §7.3). The only place in the code where brands appear.
|
|
2
|
+
|
|
3
|
+
Each preset is an ordinary Connector holding secret *references*. Default models and URLs
|
|
4
|
+
come from each provider's own docs (checked 2026-10-07); change them per connector.
|
|
5
|
+
Brand colours are approximate tiles with a two-letter monogram, never real logos.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from flowforge.connectors.models import Connector, parse_connector
|
|
13
|
+
|
|
14
|
+
DEFAULT_CONNECTORS = ("nim", "fetch") # seeded on first start: the default llm and mcp (D10)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _letters(text: str, color: str) -> dict:
|
|
18
|
+
return {"color": color, "logo": {"type": "letters", "text": text}}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
PRESETS: dict[str, Connector] = {c.id: c for c in map(parse_connector, [
|
|
22
|
+
{"id": "nim", "type": "llm", "name": "NVIDIA NIM", "role": "free hosted models (default)",
|
|
23
|
+
"style": _letters("Nv", "#76B900"), "rate_limit_rpm": 40, "secret_ref": "env:NVIDIA_API_KEY",
|
|
24
|
+
"connection": {"provider": "openai_compatible", "base_url": "https://integrate.api.nvidia.com/v1",
|
|
25
|
+
"model": "meta/llama-3.1-8b-instruct"}},
|
|
26
|
+
{"id": "gemini", "type": "llm", "name": "Gemini", "role": "Google's models",
|
|
27
|
+
"style": _letters("Ge", "#4285F4"), "secret_ref": "env:GEMINI_API_KEY",
|
|
28
|
+
"connection": {"provider": "openai_compatible",
|
|
29
|
+
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
30
|
+
"model": "gemini-3.8-flash"}},
|
|
31
|
+
{"id": "claude", "type": "llm", "name": "Claude", "role": "Anthropic's models",
|
|
32
|
+
"style": _letters("Cl", "#D97757"), "secret_ref": "env:ANTHROPIC_API_KEY",
|
|
33
|
+
"connection": {"provider": "anthropic", "model": "claude-opus-5-5"}},
|
|
34
|
+
{"id": "ollama", "type": "llm", "name": "Ollama", "role": "models on your machine",
|
|
35
|
+
"style": _letters("Ol", "#555555"), # no key: a local server; set model to one you've pulled
|
|
36
|
+
"connection": {"provider": "openai_compatible", "base_url": "http://localhost:11434/v1",
|
|
37
|
+
"model": "gpt-oss:20b"}},
|
|
38
|
+
{"id": "razorpay", "type": "http", "name": "Razorpay", "role": "payments", "slot": "payments",
|
|
39
|
+
"style": _letters("Rz", "#3395FF"), "mode": "test", "secret_ref": "env:RAZORPAY_KEY", # "key_id:key_secret"
|
|
40
|
+
"connection": {"base_url": "https://api.razorpay.com/v1", "auth_header": "Authorization",
|
|
41
|
+
"auth_scheme": "basic"}},
|
|
42
|
+
{"id": "stripe", "type": "http", "name": "Stripe", "role": "payments", "slot": "payments",
|
|
43
|
+
"style": _letters("St", "#635BFF"), "mode": "test", "secret_ref": "env:STRIPE_SECRET_KEY",
|
|
44
|
+
"connection": {"base_url": "https://api.stripe.com/v1", "auth_header": "Authorization",
|
|
45
|
+
"auth_scheme": "bearer"}},
|
|
46
|
+
{"id": "fetch", "type": "mcp", "name": "Fetch", "role": "reads web pages (default MCP)",
|
|
47
|
+
"style": _letters("Fe", "#8A8A8A"),
|
|
48
|
+
"connection": {"command": "uvx", "args": ["mcp-server-fetch"]}},
|
|
49
|
+
])}
|
|
50
|
+
|
|
51
|
+
# The connector catalog (D17): many apps, each with a mock test run. `live` means FlowForge can call it
|
|
52
|
+
# today with the user's own key or on their machine; the rest are "Demo only" until their integration lands.
|
|
53
|
+
# Fields: id, name, category, connector type, live, one-line description, meta line.
|
|
54
|
+
CATEGORIES = {"llm": "LLMs", "pay": "Payments", "dev": "Dev tools", "data": "Data and apps",
|
|
55
|
+
"media": "Media and AI services", "custom": "Your own"}
|
|
56
|
+
_CATALOG_ROWS: list[tuple[str, str, str, str, bool, str, str]] = [
|
|
57
|
+
("nim", "NVIDIA NIM", "llm", "llm", True, "Fast open models on a free tier.", "openai-compatible · 40 rpm free"),
|
|
58
|
+
("gemini", "Google Gemini", "llm", "llm", True, "Quick general model with a generous free tier.", "openai-compatible · 15 rpm free"),
|
|
59
|
+
("claude", "Claude", "llm", "llm", True, "Careful writing, code and long documents.", "anthropic · your key"),
|
|
60
|
+
("openai", "OpenAI", "llm", "llm", True, "GPT models through the standard API.", "openai-compatible · your key"),
|
|
61
|
+
("ollama", "Ollama", "llm", "llm", True, "Models on your own machine. Nothing leaves it.", "local · port 11434"),
|
|
62
|
+
("mistral", "Mistral", "llm", "llm", True, "European models, from small to large.", "openai-compatible · your key"),
|
|
63
|
+
("groq", "Groq", "llm", "llm", True, "Very fast answers from open models.", "openai-compatible · free tier"),
|
|
64
|
+
("openrouter", "OpenRouter", "llm", "llm", True, "One key for hundreds of models.", "openai-compatible · your key"),
|
|
65
|
+
("deepseek", "DeepSeek", "llm", "llm", True, "Strong reasoning at a low price.", "openai-compatible · your key"),
|
|
66
|
+
("perplexity", "Perplexity", "llm", "http", False, "Answers with web sources attached.", "HTTP · search"),
|
|
67
|
+
("huggingface", "Hugging Face", "llm", "http", False, "Inference for thousands of open models.", "HTTP · inference"),
|
|
68
|
+
("together", "Together AI", "llm", "llm", False, "Hosted open models and fine-tunes.", "openai-compatible"),
|
|
69
|
+
("cohere", "Cohere", "llm", "http", False, "Embeddings and reranking for search.", "HTTP · embed, rerank"),
|
|
70
|
+
("razorpay", "Razorpay", "pay", "http", False, "Payments in India. Starts in test mode.", "HTTP · money moves only behind a gate"),
|
|
71
|
+
("stripe", "Stripe", "pay", "http", False, "Global payments. Starts in test mode.", "HTTP · money moves only behind a gate"),
|
|
72
|
+
("paypal", "PayPal", "pay", "http", False, "PayPal wallets and cards.", "HTTP · money moves only behind a gate"),
|
|
73
|
+
("square", "Square", "pay", "http", False, "In-person and online payments.", "HTTP · money moves only behind a gate"),
|
|
74
|
+
("adyen", "Adyen", "pay", "http", False, "Payments for large shops.", "HTTP · money moves only behind a gate"),
|
|
75
|
+
("github", "GitHub", "dev", "mcp", True, "Issues, pull requests and code search.", "MCP · 26 tools"),
|
|
76
|
+
("sirius", "Sirius", "dev", "local", True, "Your security scanner, run on this machine.", "local · no shell · one folder"),
|
|
77
|
+
("playwright", "Playwright", "dev", "mcp", True, "Drives a real browser to test pages.", "MCP · 21 tools"),
|
|
78
|
+
("filesystem", "Filesystem", "dev", "mcp", True, "Reads and writes files in one folder.", "MCP · 11 tools"),
|
|
79
|
+
("gitlab", "GitLab", "dev", "mcp", False, "Merge requests, pipelines and issues.", "MCP · demo"),
|
|
80
|
+
("docker", "Docker", "dev", "mcp", False, "Start, stop and inspect containers.", "MCP · demo"),
|
|
81
|
+
("sentry", "Sentry", "dev", "mcp", False, "Errors and traces from production.", "MCP · demo"),
|
|
82
|
+
("vercel", "Vercel", "dev", "mcp", False, "Deploys, domains and logs.", "MCP · demo"),
|
|
83
|
+
("linear", "Linear", "dev", "mcp", False, "Issues and projects.", "MCP · demo"),
|
|
84
|
+
("jira", "Jira", "dev", "http", False, "Tickets and sprints.", "HTTP · demo"),
|
|
85
|
+
("fetch", "Fetch", "data", "mcp", True, "Fetches any web page as clean text.", "MCP · uvx mcp-server-fetch"),
|
|
86
|
+
("postgres", "PostgreSQL", "data", "mcp", True, "Asks your database questions, read-only.", "MCP · 3 tools · read-only"),
|
|
87
|
+
("supabase", "Supabase", "data", "mcp", False, "Postgres, auth and storage in one.", "MCP · demo"),
|
|
88
|
+
("mongodb", "MongoDB", "data", "mcp", False, "Document database queries.", "MCP · demo"),
|
|
89
|
+
("redis", "Redis", "data", "http", False, "Fast key-value cache.", "HTTP · demo"),
|
|
90
|
+
("notion", "Notion", "data", "mcp", False, "Pages and databases in your workspace.", "MCP · demo"),
|
|
91
|
+
("slack", "Slack", "data", "mcp", False, "Post and read messages in channels.", "MCP · demo · sending waits for you"),
|
|
92
|
+
("gmail", "Gmail", "data", "mcp", False, "Send and read email.", "MCP · demo · sending waits for you"),
|
|
93
|
+
("gdrive", "Google Drive", "data", "mcp", False, "Find and read files in Drive.", "MCP · demo"),
|
|
94
|
+
("gsheets", "Google Sheets", "data", "http", False, "Read and append rows.", "HTTP · demo"),
|
|
95
|
+
("airtable", "Airtable", "data", "http", False, "Tables your team already uses.", "HTTP · demo"),
|
|
96
|
+
("discord", "Discord", "data", "http", False, "Messages and webhooks.", "HTTP · demo"),
|
|
97
|
+
("telegram", "Telegram", "data", "http", False, "Bots and chat messages.", "HTTP · demo"),
|
|
98
|
+
("whatsapp", "WhatsApp", "data", "http", False, "Business messages to customers.", "HTTP · demo · sending waits for you"),
|
|
99
|
+
("twilio", "Twilio", "data", "http", False, "SMS and voice calls.", "HTTP · demo · sending waits for you"),
|
|
100
|
+
("resend", "Resend", "data", "http", False, "Email from your app.", "HTTP · demo"),
|
|
101
|
+
("shopify", "Shopify", "data", "http", False, "Products, orders and customers.", "HTTP · demo"),
|
|
102
|
+
("firebase", "Firebase", "data", "http", False, "App data and auth.", "HTTP · demo"),
|
|
103
|
+
("higgsfield", "Higgsfield", "media", "http", False, "Images and video from a prompt.", "HTTP · demo"),
|
|
104
|
+
("elevenlabs", "ElevenLabs", "media", "http", False, "Natural voices and speech.", "HTTP · demo"),
|
|
105
|
+
("replicate", "Replicate", "media", "http", False, "Run open models by API.", "HTTP · demo"),
|
|
106
|
+
("stability", "Stability AI", "media", "http", False, "Image generation and editing.", "HTTP · demo"),
|
|
107
|
+
("runway", "Runway", "media", "http", False, "Video generation and editing.", "HTTP · demo"),
|
|
108
|
+
("deepgram", "Deepgram", "media", "http", False, "Speech to text, fast.", "HTTP · demo"),
|
|
109
|
+
("assemblyai", "AssemblyAI", "media", "http", False, "Speech to text and audio insights.", "HTTP · demo"),
|
|
110
|
+
("custom_mcp", "Any MCP server", "custom", "mcp", True, "A command and its arguments. Tools are found for you.", "MCP · your colour and logo"),
|
|
111
|
+
("custom_http", "Any HTTP API", "custom", "http", True, "A base URL and the name of its key.", "HTTP · OpenAPI optional"),
|
|
112
|
+
("custom_local", "Local command", "custom", "local", True, "Any script on your machine. No shell, one folder.", "local · your colour and logo"),
|
|
113
|
+
]
|
|
114
|
+
CATALOG: list[dict] = [
|
|
115
|
+
{"id": r[0], "name": r[1], "category": r[2], "type": r[3], "live": r[4], "description": r[5], "meta": r[6]}
|
|
116
|
+
for r in _CATALOG_ROWS
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
# What the repo scanner recognises (D21). One row per service: id (the catalog id where there is one), name,
|
|
120
|
+
# kind, hosts it answers on (a leading "." matches any subdomain; "host:port" for local servers) and its SDK
|
|
121
|
+
# packages (Python import or dist names, npm names, Dart packages; compared case-insensitively with - _ . alike).
|
|
122
|
+
_SERVICE_ROWS: list[tuple[str, str, str, tuple[str, ...], tuple[str, ...]]] = [
|
|
123
|
+
("ollama", "Ollama", "llm", ("localhost:11434", "127.0.0.1:11434"), ("ollama", "langchain_ollama", "ollama-ai-provider")),
|
|
124
|
+
("nim", "NVIDIA NIM", "llm", ("integrate.api.nvidia.com",), ("langchain_nvidia_ai_endpoints",)),
|
|
125
|
+
("openai", "OpenAI", "llm", ("api.openai.com",), ("openai", "langchain_openai", "@ai-sdk/openai", "dart_openai")),
|
|
126
|
+
("claude", "Claude", "llm", ("api.anthropic.com",), ("anthropic", "@anthropic-ai/sdk", "langchain_anthropic", "@ai-sdk/anthropic")),
|
|
127
|
+
("gemini", "Google Gemini", "llm", ("generativelanguage.googleapis.com",),
|
|
128
|
+
("google.generativeai", "google.genai", "google-generativeai", "google-genai", "@google/generative-ai", "@google/genai",
|
|
129
|
+
"langchain_google_genai", "google_generative_ai", "@ai-sdk/google")),
|
|
130
|
+
("mistral", "Mistral", "llm", ("api.mistral.ai",), ("mistralai", "@mistralai/mistralai", "langchain_mistralai")),
|
|
131
|
+
("cohere", "Cohere", "llm", ("api.cohere.com", "api.cohere.ai"), ("cohere", "cohere-ai", "langchain_cohere")),
|
|
132
|
+
("groq", "Groq", "llm", ("api.groq.com",), ("groq", "groq-sdk", "langchain_groq")),
|
|
133
|
+
("openrouter", "OpenRouter", "llm", ("openrouter.ai",), ("@openrouter/ai-sdk-provider",)),
|
|
134
|
+
("deepseek", "DeepSeek", "llm", ("api.deepseek.com",), ()),
|
|
135
|
+
("supabase", "Supabase", "api", (".supabase.co",), ("supabase", "@supabase/supabase-js", "supabase_flutter")),
|
|
136
|
+
("stripe", "Stripe", "api", ("api.stripe.com",), ("stripe", "flutter_stripe", "@stripe/stripe-js")),
|
|
137
|
+
("razorpay", "Razorpay", "api", ("api.razorpay.com",), ("razorpay", "razorpay_flutter")),
|
|
138
|
+
("firebase", "Firebase", "api", (".firebaseio.com", "firestore.googleapis.com", ".firebaseapp.com"),
|
|
139
|
+
("firebase_admin", "firebase", "firebase-admin", "firebase_core", "cloud_firestore", "firebase_auth")),
|
|
140
|
+
("google_maps", "Google Maps", "api", ("maps.googleapis.com",), ("googlemaps", "google_maps_flutter", "@googlemaps/js-api-loader")),
|
|
141
|
+
("osm_tiles", "OpenStreetMap tiles", "api", ("tile.openstreetmap.org",), ()),
|
|
142
|
+
("nominatim", "Nominatim", "api", ("nominatim.openstreetmap.org",), ()),
|
|
143
|
+
("overpass", "Overpass API", "api", ("overpass-api.de",), ()),
|
|
144
|
+
("osrm", "OSRM", "api", ("router.project-osrm.org",), ()),
|
|
145
|
+
("open_meteo", "Open-Meteo", "api", ("api.open-meteo.com",), ()),
|
|
146
|
+
("wikipedia", "Wikipedia", "api", (".wikipedia.org",), ()),
|
|
147
|
+
("wikimedia", "Wikimedia", "api", (".wikimedia.org",), ()),
|
|
148
|
+
]
|
|
149
|
+
SERVICES: list[dict] = [{"id": r[0], "name": r[1], "kind": r[2], "hosts": list(r[3]), "sdks": list(r[4])}
|
|
150
|
+
for r in _SERVICE_ROWS]
|
|
151
|
+
# Where a health check reads an LLM's model list, with no key (D24). "{host}" is the address the code uses.
|
|
152
|
+
MODEL_LISTS: dict[str, str] = {
|
|
153
|
+
"ollama": "http://{host}/api/tags", "nim": "https://integrate.api.nvidia.com/v1/models",
|
|
154
|
+
"openai": "https://api.openai.com/v1/models", "claude": "https://api.anthropic.com/v1/models",
|
|
155
|
+
"gemini": "https://generativelanguage.googleapis.com/v1beta/models", "mistral": "https://api.mistral.ai/v1/models",
|
|
156
|
+
"cohere": "https://api.cohere.com/v1/models", "groq": "https://api.groq.com/openai/v1/models",
|
|
157
|
+
"openrouter": "https://openrouter.ai/api/v1/models", "deepseek": "https://api.deepseek.com/models",
|
|
158
|
+
}
|
|
159
|
+
# How a service's SDK client is reached in Dart/JS/TS code (D32): a call through it, or through a name bound to it,
|
|
160
|
+
# is a call to that service
|
|
161
|
+
CLIENTS: dict[str, tuple[str, ...]] = {
|
|
162
|
+
"supabase": (r"Supabase\.instance\.client", r"\bcreateClient\s*\(", r"\bSupabaseClient\s*\("),
|
|
163
|
+
"firebase": (r"FirebaseFirestore\.instance", r"\bgetFirestore\s*\(", r"FirebaseAuth\.instance"),
|
|
164
|
+
}
|
|
165
|
+
# SDKs that route to many providers (D21): the provider comes from the model name, by these prefixes
|
|
166
|
+
ROUTER_SDKS = ("litellm",)
|
|
167
|
+
MODEL_PREFIXES: dict[str, tuple[str, ...]] = {
|
|
168
|
+
"claude": ("anthropic/", "claude-"), "openai": ("openai/", "gpt-", "o1", "o3", "o4"),
|
|
169
|
+
"gemini": ("gemini/", "gemini-", "vertex_ai/gemini"), "ollama": ("ollama/", "ollama_chat/"), "groq": ("groq/",),
|
|
170
|
+
"mistral": ("mistral/", "mistral-", "codestral"), "openrouter": ("openrouter/",), "nim": ("nvidia_nim/",),
|
|
171
|
+
"deepseek": ("deepseek/",), "cohere": ("cohere/", "command-"),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# Starting points for the "Custom" choice of each type; the user fills in id, name and connection.
|
|
175
|
+
CUSTOM: dict[str, dict] = {
|
|
176
|
+
"llm": {"type": "llm", "connection": {"provider": "openai_compatible", "base_url": "", "model": ""}},
|
|
177
|
+
"mcp": {"type": "mcp", "connection": {"command": "", "args": []}},
|
|
178
|
+
"http": {"type": "http", "connection": {"base_url": "", "auth_header": "Authorization", "auth_scheme": "bearer"}},
|
|
179
|
+
"local": {"type": "local", "connection": {"command": [], "cwd": ""}},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def with_env_overrides(connector: Connector) -> Connector:
|
|
184
|
+
"""The default NIM connector keeps honouring NIM_RPM, NIM_BASE_URL and NIM_DEFAULT_MODEL (D8, D10)."""
|
|
185
|
+
if connector.id != "nim" or connector.type != "llm":
|
|
186
|
+
return connector
|
|
187
|
+
connection = connector.connection.model_copy(update={
|
|
188
|
+
"base_url": os.getenv("NIM_BASE_URL") or connector.connection.base_url,
|
|
189
|
+
"model": os.getenv("NIM_DEFAULT_MODEL") or connector.connection.model,
|
|
190
|
+
})
|
|
191
|
+
rpm = float(os.getenv("NIM_RPM")) if os.getenv("NIM_RPM") else connector.rate_limit_rpm
|
|
192
|
+
return connector.model_copy(update={"connection": connection, "rate_limit_rpm": rpm})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Connector registry: the `connectors` table, default seeding and connectors.json (D10).
|
|
2
|
+
|
|
3
|
+
Phase 1 has no write routes. Connectors come from the seeded defaults (nim, fetch) and the
|
|
4
|
+
optional FLOWFORGE_HOME/connectors.json, a JSON list of connectors validated by the same
|
|
5
|
+
models and upserted at start-up. A bad file stops start-up with a clear message rather
|
|
6
|
+
than running with half the connectors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from flowforge.connectors.models import Connector, parse_connector
|
|
17
|
+
from flowforge.connectors.presets import DEFAULT_CONNECTORS, PRESETS
|
|
18
|
+
from flowforge.storage import Storage
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConnectorFileError(ValueError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Registry:
|
|
26
|
+
def __init__(self, storage: Storage):
|
|
27
|
+
self.storage = storage
|
|
28
|
+
|
|
29
|
+
def all(self) -> list[Connector]:
|
|
30
|
+
return [parse_connector(json.loads(raw)) for _, raw in self.storage.connector_rows()]
|
|
31
|
+
|
|
32
|
+
def get(self, connector_id: str) -> Connector | None:
|
|
33
|
+
return next((c for c in self.all() if c.id == connector_id), None)
|
|
34
|
+
|
|
35
|
+
def save(self, connector: Connector) -> None:
|
|
36
|
+
self.storage.put_connector(connector.id, connector.model_dump_json())
|
|
37
|
+
|
|
38
|
+
def delete(self, connector_id: str) -> None:
|
|
39
|
+
self.storage.delete_connector(connector_id)
|
|
40
|
+
|
|
41
|
+
def ensure_defaults(self) -> None:
|
|
42
|
+
"""Seed the default connectors once; later edits to them are kept."""
|
|
43
|
+
existing = {c.id for c in self.all()}
|
|
44
|
+
for connector_id in DEFAULT_CONNECTORS:
|
|
45
|
+
if connector_id not in existing:
|
|
46
|
+
self.save(PRESETS[connector_id])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _describe(exc: ValidationError) -> str:
|
|
50
|
+
"""Field paths and messages only: never echo input values (a pasted key must not be repeated)."""
|
|
51
|
+
return "; ".join(
|
|
52
|
+
f"{'.'.join(map(str, e['loc'])) or 'connector'}: {e['msg']}"
|
|
53
|
+
for e in exc.errors(include_input=False, include_url=False)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_connectors_file(path: Path) -> list[Connector]:
|
|
58
|
+
if not path.exists():
|
|
59
|
+
return []
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
except json.JSONDecodeError as exc:
|
|
63
|
+
raise ConnectorFileError(f"{path}: not valid JSON (line {exc.lineno}, column {exc.colno})") from None
|
|
64
|
+
if not isinstance(data, list):
|
|
65
|
+
raise ConnectorFileError(f"{path}: expected a JSON list of connectors")
|
|
66
|
+
connectors: list[Connector] = []
|
|
67
|
+
for i, item in enumerate(data):
|
|
68
|
+
try:
|
|
69
|
+
connectors.append(parse_connector(item))
|
|
70
|
+
except ValidationError as exc:
|
|
71
|
+
raise ConnectorFileError(f"{path}: connector #{i + 1}: {_describe(exc)}") from None
|
|
72
|
+
return connectors
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_references(connectors: list[Connector]) -> None:
|
|
76
|
+
"""Every fallback names an existing connector of the same type (D10)."""
|
|
77
|
+
by_id = {c.id: c for c in connectors}
|
|
78
|
+
for c in connectors:
|
|
79
|
+
if c.fallback is None:
|
|
80
|
+
continue
|
|
81
|
+
target = by_id.get(c.fallback)
|
|
82
|
+
if target is None:
|
|
83
|
+
raise ConnectorFileError(f"connector '{c.id}' falls back to '{c.fallback}', which does not exist")
|
|
84
|
+
if target.type != c.type:
|
|
85
|
+
raise ConnectorFileError(f"connector '{c.id}' ({c.type}) falls back to '{c.fallback}' ({target.type})")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Connectors → runtime nodes and rate-limit buckets (D10, D16)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from flowforge.connectors.models import Connector
|
|
8
|
+
from flowforge.connectors.presets import with_env_overrides
|
|
9
|
+
from flowforge.nodes.base import Node
|
|
10
|
+
from flowforge.nodes.check_node import CheckNode
|
|
11
|
+
from flowforge.nodes.http_node import HTTPNode
|
|
12
|
+
from flowforge.nodes.llm_node import LLMNode
|
|
13
|
+
from flowforge.nodes.local_node import LocalNode
|
|
14
|
+
from flowforge.nodes.mcp_node import MCPNode
|
|
15
|
+
from flowforge.nodes.mock_node import MockNode
|
|
16
|
+
from flowforge.scheduler.rate_limit import TokenBucket
|
|
17
|
+
|
|
18
|
+
NODE_BUILDERS = {"llm": LLMNode.from_connector, "mcp": MCPNode.from_connector,
|
|
19
|
+
"http": HTTPNode.from_connector, "local": LocalNode.from_connector}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_node(connector: Connector) -> tuple[Node, TokenBucket | None]:
|
|
23
|
+
"""The node for one connector, and its own bucket if it has a rate limit."""
|
|
24
|
+
connector = with_env_overrides(connector)
|
|
25
|
+
node = NODE_BUILDERS[connector.type](connector)
|
|
26
|
+
if connector.id == "nim" and isinstance(node, LLMNode):
|
|
27
|
+
node.default_for_type = True # the default llm keeps the V1 cache namespace
|
|
28
|
+
bucket = TokenBucket(connector.rate_limit_rpm) if connector.rate_limit_rpm and node.rate_limit_key else None
|
|
29
|
+
return node, bucket
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_nodes(connectors: list[Connector]) -> tuple[dict[str, Node], dict[str, TokenBucket]]:
|
|
33
|
+
"""One node per connector, keyed by id, plus each type's default under the type name (D10).
|
|
34
|
+
|
|
35
|
+
The default llm is the `nim` connector; it keeps the V1 cache namespace and the "nim"
|
|
36
|
+
bucket. mcp and http keep their V1 nodes as defaults (a step may still pass params.server).
|
|
37
|
+
"""
|
|
38
|
+
nodes: dict[str, Node] = {"http": HTTPNode(), "mcp": MCPNode(), "mock": MockNode(), "check": CheckNode()}
|
|
39
|
+
buckets: dict[str, TokenBucket] = {}
|
|
40
|
+
for connector in connectors:
|
|
41
|
+
node, bucket = build_node(connector)
|
|
42
|
+
nodes[connector.id] = node
|
|
43
|
+
if bucket is not None and node.rate_limit_key:
|
|
44
|
+
buckets[node.rate_limit_key] = bucket
|
|
45
|
+
nodes["llm"] = nodes["nim"] if isinstance(nodes.get("nim"), LLMNode) else LLMNode()
|
|
46
|
+
buckets.setdefault("nim", TokenBucket(float(os.getenv("NIM_RPM", "40"))))
|
|
47
|
+
return nodes, buckets
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Secret references (D10). A connector holds `env:NAME` or `vault:NAME`, never a value.
|
|
2
|
+
|
|
3
|
+
Phase 1 resolves `env:` from the process environment. `vault:` is reserved for the
|
|
4
|
+
Phase 4 vault and refused until then. Errors name the reference, never a value.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
SECRET_REF_PATTERN = r"^(env|vault):[A-Z_][A-Z0-9_]*$"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SecretRefError(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# A key pasted (and upper-cased) into a "variable name" field still has its key shape (D16).
|
|
20
|
+
_KEY_SHAPED_NAME = re.compile(
|
|
21
|
+
r"^(?:(?:SK|RK)_(?:LIVE|TEST)_|RZP_(?:LIVE|TEST)_|AIZA[0-9A-Z_]{16,}|SK_ANT_)", re.IGNORECASE)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_ref(ref: str | None) -> str | None:
|
|
25
|
+
"""Pydantic-friendly: refuse a reference whose variable name is really a key."""
|
|
26
|
+
if ref is not None and _KEY_SHAPED_NAME.match(ref.split(":", 1)[-1]):
|
|
27
|
+
raise ValueError("that variable name looks like a key; put the key in .env and reference it by name")
|
|
28
|
+
return ref
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_secret(ref: str | None) -> str | None:
|
|
32
|
+
"""The secret value for `ref`, or None when no secret is configured."""
|
|
33
|
+
if ref is None:
|
|
34
|
+
return None
|
|
35
|
+
scheme, _, name = ref.partition(":")
|
|
36
|
+
if scheme == "vault":
|
|
37
|
+
raise SecretRefError(f"secret '{ref}' needs the vault, which arrives in Phase 4; use env:{name} for now")
|
|
38
|
+
if scheme != "env" or not name:
|
|
39
|
+
raise SecretRefError(f"unsupported secret reference '{ref}'")
|
|
40
|
+
value = os.environ.get(name)
|
|
41
|
+
if not value:
|
|
42
|
+
raise SecretRefError(f"{name} is not set (referenced as '{ref}'); add it to .env")
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_env(env: dict[str, str], env_refs: dict[str, str]) -> dict[str, str]:
|
|
47
|
+
"""Plain env values plus resolved secret env values (D12, D13)."""
|
|
48
|
+
resolved = dict(env)
|
|
49
|
+
for var, ref in env_refs.items():
|
|
50
|
+
value = resolve_secret(ref)
|
|
51
|
+
if value is not None:
|
|
52
|
+
resolved[var] = value
|
|
53
|
+
return resolved
|