fusion-core 0.12.1__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.
- fusion_core/__init__.py +227 -0
- fusion_core/config.py +291 -0
- fusion_core/dsl_schema.py +150 -0
- fusion_core/errors.py +51 -0
- fusion_core/guard_client.py +432 -0
- fusion_core/http/__init__.py +5 -0
- fusion_core/http/app.py +245 -0
- fusion_core/http_client.py +391 -0
- fusion_core/logging.py +140 -0
- fusion_core/mcp-server-registry.json +28 -0
- fusion_core/mcp_client.py +112 -0
- fusion_core/mcp_registry.py +237 -0
- fusion_core/mlx_client.py +447 -0
- fusion_core/parse.py +91 -0
- fusion_core/proc.py +15 -0
- fusion_core/prompt.py +72 -0
- fusion_core/prompt_registry.py +206 -0
- fusion_core/tenant/__init__.py +39 -0
- fusion_core/tenant/context.py +56 -0
- fusion_core/tenant/deps.py +22 -0
- fusion_core/tenant/jwt_utils.py +62 -0
- fusion_core/tenant/middleware.py +162 -0
- fusion_core-0.12.1.dist-info/METADATA +27 -0
- fusion_core-0.12.1.dist-info/RECORD +27 -0
- fusion_core-0.12.1.dist-info/WHEEL +5 -0
- fusion_core-0.12.1.dist-info/licenses/LICENSE +201 -0
- fusion_core-0.12.1.dist-info/top_level.txt +1 -0
fusion_core/__init__.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging as _stdlib_logging
|
|
4
|
+
from importlib.metadata import PackageNotFoundError
|
|
5
|
+
from importlib.metadata import version as _pkg_version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = _pkg_version("fusion-core")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
__version__ = "0.0.0+unknown"
|
|
11
|
+
|
|
12
|
+
_logger = _stdlib_logging.getLogger("fusion_core")
|
|
13
|
+
if not _logger.handlers:
|
|
14
|
+
_handler = _stdlib_logging.NullHandler()
|
|
15
|
+
_logger.addHandler(_handler)
|
|
16
|
+
|
|
17
|
+
from fusion_core.config import ( # noqa: E402
|
|
18
|
+
admin_key,
|
|
19
|
+
clear_cache,
|
|
20
|
+
client_key,
|
|
21
|
+
default_gateway_base_url,
|
|
22
|
+
default_guard_sock,
|
|
23
|
+
default_mlx_base_url,
|
|
24
|
+
gateway_admin_key,
|
|
25
|
+
gateway_base_url,
|
|
26
|
+
get_env,
|
|
27
|
+
load_api_key,
|
|
28
|
+
load_dotenv,
|
|
29
|
+
load_settings,
|
|
30
|
+
profile,
|
|
31
|
+
resolve_api_key,
|
|
32
|
+
service_data_dir,
|
|
33
|
+
service_port,
|
|
34
|
+
)
|
|
35
|
+
from fusion_core.dsl_schema import ( # noqa: E402
|
|
36
|
+
SOCRATIC_DSL_V3_SCHEMA,
|
|
37
|
+
validate_dsl,
|
|
38
|
+
validate_dsl_str,
|
|
39
|
+
)
|
|
40
|
+
from fusion_core.errors import FusionError # noqa: E402
|
|
41
|
+
from fusion_core.guard_client import ( # noqa: E402
|
|
42
|
+
AllChainsVerification,
|
|
43
|
+
ChainVerification,
|
|
44
|
+
FusionGuardClient,
|
|
45
|
+
GuardEngineError,
|
|
46
|
+
GuardError,
|
|
47
|
+
GuardInvalidParamsError,
|
|
48
|
+
GuardInvalidRequestError,
|
|
49
|
+
GuardMethodNotFoundError,
|
|
50
|
+
GuardParseError,
|
|
51
|
+
GuardRateLimitError,
|
|
52
|
+
GuardRule,
|
|
53
|
+
GuardUnauthorizedError,
|
|
54
|
+
GuardVerdict,
|
|
55
|
+
RedactResult,
|
|
56
|
+
StaleEpochError,
|
|
57
|
+
)
|
|
58
|
+
from fusion_core.http_client import ( # noqa: E402
|
|
59
|
+
close_all,
|
|
60
|
+
gateway_circuit_breaker_ok,
|
|
61
|
+
get_async_client,
|
|
62
|
+
get_metrics_snapshot,
|
|
63
|
+
reset_metrics,
|
|
64
|
+
set_metrics_callback,
|
|
65
|
+
with_retry,
|
|
66
|
+
)
|
|
67
|
+
from fusion_core.logging import get_logger, setup_logging # noqa: E402
|
|
68
|
+
from fusion_core.mcp_client import ( # noqa: E402
|
|
69
|
+
McpToolClient,
|
|
70
|
+
McpToolError,
|
|
71
|
+
known_servers,
|
|
72
|
+
)
|
|
73
|
+
from fusion_core.mcp_registry import ( # noqa: E402
|
|
74
|
+
ApprovalVerdict,
|
|
75
|
+
McpRegistryError,
|
|
76
|
+
McpServerRecord,
|
|
77
|
+
RegistryIntegrityError,
|
|
78
|
+
RegistryLookupError,
|
|
79
|
+
check_approval,
|
|
80
|
+
load_registry,
|
|
81
|
+
resolve_server,
|
|
82
|
+
)
|
|
83
|
+
from fusion_core.mlx_client import ( # noqa: E402
|
|
84
|
+
EmbeddingResponse,
|
|
85
|
+
FusionMLXClient,
|
|
86
|
+
LLMResponse,
|
|
87
|
+
create_async_client,
|
|
88
|
+
)
|
|
89
|
+
from fusion_core.parse import ( # noqa: E402
|
|
90
|
+
ParseError,
|
|
91
|
+
parse_llm_json,
|
|
92
|
+
parse_llm_json_lenient,
|
|
93
|
+
parse_llm_json_safe,
|
|
94
|
+
strip_code_fence,
|
|
95
|
+
)
|
|
96
|
+
from fusion_core.proc import set_process_title # noqa: E402
|
|
97
|
+
from fusion_core.prompt import PromptManager # noqa: E402
|
|
98
|
+
from fusion_core.prompt_registry import ( # noqa: E402
|
|
99
|
+
PromptVersionError,
|
|
100
|
+
PromptVersionRecord,
|
|
101
|
+
PromptVersionRegistry,
|
|
102
|
+
SnapshotIntegrityError,
|
|
103
|
+
UnknownPromptVersionError,
|
|
104
|
+
compute_version_id,
|
|
105
|
+
is_valid_version_id,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
from fusion_core.tenant import ( # noqa: F401
|
|
110
|
+
TenantContext,
|
|
111
|
+
TenantContextError,
|
|
112
|
+
TenantMiddleware,
|
|
113
|
+
current,
|
|
114
|
+
decode_jwt_claims,
|
|
115
|
+
from_mapping,
|
|
116
|
+
has_scope,
|
|
117
|
+
install_tenant_middleware,
|
|
118
|
+
reset,
|
|
119
|
+
set_context,
|
|
120
|
+
tenant_context_from_token,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
_tenant_available = True
|
|
124
|
+
except ImportError:
|
|
125
|
+
_tenant_available = False
|
|
126
|
+
_logger.debug("fusion_core.tenant unavailable (missing dep?)")
|
|
127
|
+
|
|
128
|
+
__all__ = [
|
|
129
|
+
"__version__",
|
|
130
|
+
"ParseError",
|
|
131
|
+
"parse_llm_json",
|
|
132
|
+
"parse_llm_json_lenient",
|
|
133
|
+
"parse_llm_json_safe",
|
|
134
|
+
"strip_code_fence",
|
|
135
|
+
"get_env",
|
|
136
|
+
"load_api_key",
|
|
137
|
+
"load_dotenv",
|
|
138
|
+
"FusionError",
|
|
139
|
+
"resolve_api_key",
|
|
140
|
+
"load_settings",
|
|
141
|
+
"clear_cache",
|
|
142
|
+
"client_key",
|
|
143
|
+
"admin_key",
|
|
144
|
+
"gateway_admin_key",
|
|
145
|
+
"gateway_base_url",
|
|
146
|
+
"service_port",
|
|
147
|
+
"service_data_dir",
|
|
148
|
+
"profile",
|
|
149
|
+
"default_mlx_base_url",
|
|
150
|
+
"default_gateway_base_url",
|
|
151
|
+
"default_guard_sock",
|
|
152
|
+
"get_logger",
|
|
153
|
+
"setup_logging",
|
|
154
|
+
"FusionMLXClient",
|
|
155
|
+
"LLMResponse",
|
|
156
|
+
"EmbeddingResponse",
|
|
157
|
+
"create_async_client",
|
|
158
|
+
"McpServerRecord",
|
|
159
|
+
"ApprovalVerdict",
|
|
160
|
+
"McpRegistryError",
|
|
161
|
+
"RegistryIntegrityError",
|
|
162
|
+
"RegistryLookupError",
|
|
163
|
+
"load_registry",
|
|
164
|
+
"resolve_server",
|
|
165
|
+
"check_approval",
|
|
166
|
+
"McpToolClient",
|
|
167
|
+
"McpToolError",
|
|
168
|
+
"known_servers",
|
|
169
|
+
"get_async_client",
|
|
170
|
+
"with_retry",
|
|
171
|
+
"gateway_circuit_breaker_ok",
|
|
172
|
+
"close_all",
|
|
173
|
+
"set_metrics_callback",
|
|
174
|
+
"get_metrics_snapshot",
|
|
175
|
+
"reset_metrics",
|
|
176
|
+
"PromptManager",
|
|
177
|
+
"set_process_title",
|
|
178
|
+
"PromptVersionRegistry",
|
|
179
|
+
"PromptVersionRecord",
|
|
180
|
+
"PromptVersionError",
|
|
181
|
+
"UnknownPromptVersionError",
|
|
182
|
+
"SnapshotIntegrityError",
|
|
183
|
+
"compute_version_id",
|
|
184
|
+
"is_valid_version_id",
|
|
185
|
+
"SOCRATIC_DSL_V3_SCHEMA",
|
|
186
|
+
"validate_dsl",
|
|
187
|
+
"validate_dsl_str",
|
|
188
|
+
"FusionGuardClient",
|
|
189
|
+
"GuardVerdict",
|
|
190
|
+
"GuardRule",
|
|
191
|
+
"RedactResult",
|
|
192
|
+
"ChainVerification",
|
|
193
|
+
"AllChainsVerification",
|
|
194
|
+
"GuardError",
|
|
195
|
+
"GuardParseError",
|
|
196
|
+
"GuardInvalidRequestError",
|
|
197
|
+
"GuardMethodNotFoundError",
|
|
198
|
+
"GuardInvalidParamsError",
|
|
199
|
+
"GuardRateLimitError",
|
|
200
|
+
"GuardUnauthorizedError",
|
|
201
|
+
"GuardEngineError",
|
|
202
|
+
"StaleEpochError",
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
from fusion_core.http import create_app, install_auth, standard_error_handler # noqa: F401
|
|
207
|
+
|
|
208
|
+
__all__.extend(["create_app", "install_auth", "standard_error_handler"])
|
|
209
|
+
except ImportError:
|
|
210
|
+
_logger.debug("fastapi not installed; fusion_core.http factory unavailable")
|
|
211
|
+
|
|
212
|
+
if _tenant_available:
|
|
213
|
+
__all__.extend(
|
|
214
|
+
[
|
|
215
|
+
"TenantContext",
|
|
216
|
+
"TenantContextError",
|
|
217
|
+
"TenantMiddleware",
|
|
218
|
+
"current",
|
|
219
|
+
"decode_jwt_claims",
|
|
220
|
+
"from_mapping",
|
|
221
|
+
"has_scope",
|
|
222
|
+
"install_tenant_middleware",
|
|
223
|
+
"reset",
|
|
224
|
+
"set_context",
|
|
225
|
+
"tenant_context_from_token",
|
|
226
|
+
]
|
|
227
|
+
)
|
fusion_core/config.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
DEFAULT_MLX_PORT = 11434
|
|
12
|
+
DEFAULT_GATEWAY_PORT = 11432
|
|
13
|
+
|
|
14
|
+
_SETTINGS_CACHE: dict[str, tuple[dict, float]] = {}
|
|
15
|
+
|
|
16
|
+
_BOOL_TRUE = {"1", "true", "yes", "on", "y", "t"}
|
|
17
|
+
_BOOL_FALSE = {"0", "false", "no", "off", "n", "f"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def default_mlx_base_url() -> str:
|
|
21
|
+
return os.environ.get("FUSION_MLX_URL", f"http://localhost:{DEFAULT_MLX_PORT}/v1")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def default_gateway_base_url() -> str:
|
|
25
|
+
return os.environ.get("FUSION_GATEWAY_URL", f"http://localhost:{DEFAULT_GATEWAY_PORT}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DEFAULT_GUARD_SOCK = "/tmp/fusion-guard.sock"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_guard_sock() -> str:
|
|
32
|
+
return os.environ.get("FUSION_GUARD_SOCK", DEFAULT_GUARD_SOCK)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_env(key: str, default: Any = None, *, cast: type = str) -> Any:
|
|
36
|
+
raw = os.environ.get(key)
|
|
37
|
+
if raw is None or raw == "":
|
|
38
|
+
return default
|
|
39
|
+
if cast is bool:
|
|
40
|
+
return raw.strip().lower() in _BOOL_TRUE
|
|
41
|
+
try:
|
|
42
|
+
return cast(raw)
|
|
43
|
+
except (TypeError, ValueError) as exc:
|
|
44
|
+
logger.warning("get_env cast %s for %s failed: %s", cast.__name__, key, exc)
|
|
45
|
+
return default
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_settings(path: str | Path | None = None) -> dict:
|
|
49
|
+
if path is None:
|
|
50
|
+
path = Path.home() / ".fusion-mlx" / "settings.json"
|
|
51
|
+
key = str(path)
|
|
52
|
+
p = Path(path)
|
|
53
|
+
try:
|
|
54
|
+
mtime = p.stat().st_mtime
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
raise FileNotFoundError(f"settings file not found: {key}") from None
|
|
57
|
+
cached = _SETTINGS_CACHE.get(key)
|
|
58
|
+
if cached is not None:
|
|
59
|
+
data, cached_mtime = cached
|
|
60
|
+
if cached_mtime == mtime:
|
|
61
|
+
return data
|
|
62
|
+
logger.info("load_settings cache invalidated (mtime changed) for %s", key)
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
65
|
+
except json.JSONDecodeError as exc:
|
|
66
|
+
logger.warning("load_settings malformed json %s: %s", key, exc)
|
|
67
|
+
raise
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
logger.warning("load_settings read %s failed: %s", key, exc)
|
|
70
|
+
raise
|
|
71
|
+
if not isinstance(data, dict):
|
|
72
|
+
raise ValueError(f"settings file {key} is not a json object")
|
|
73
|
+
_SETTINGS_CACHE[key] = (data, mtime)
|
|
74
|
+
return data
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_api_key(
|
|
78
|
+
explicit: str | None = None,
|
|
79
|
+
*,
|
|
80
|
+
env_var: str = "FUSION_MLX_API_KEY",
|
|
81
|
+
settings_path: str | Path | None = None,
|
|
82
|
+
) -> str:
|
|
83
|
+
if explicit:
|
|
84
|
+
return explicit
|
|
85
|
+
key = os.environ.get(env_var, "")
|
|
86
|
+
if key:
|
|
87
|
+
return key
|
|
88
|
+
try:
|
|
89
|
+
cfg = load_settings(settings_path)
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
logger.debug("resolve_api_key: settings.json absent, no api_key resolved")
|
|
92
|
+
return ""
|
|
93
|
+
auth = cfg.get("auth", {})
|
|
94
|
+
if not isinstance(auth, dict):
|
|
95
|
+
logger.warning(
|
|
96
|
+
"resolve_api_key: auth is not a dict in settings, got %s; cannot read api_key",
|
|
97
|
+
type(auth).__name__,
|
|
98
|
+
)
|
|
99
|
+
return ""
|
|
100
|
+
raw = auth.get("api_key", "")
|
|
101
|
+
if raw is None:
|
|
102
|
+
logger.warning("resolve_api_key: auth.api_key is null in settings.json")
|
|
103
|
+
return ""
|
|
104
|
+
if not isinstance(raw, str):
|
|
105
|
+
logger.warning("resolve_api_key: auth.api_key not a string, got %s", type(raw).__name__)
|
|
106
|
+
return ""
|
|
107
|
+
return raw
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_api_key(name: str = "fusion-mlx") -> str:
|
|
111
|
+
short = name.removeprefix("fusion-")
|
|
112
|
+
env_var = f"FUSION_{short.upper().replace('-', '_')}_API_KEY"
|
|
113
|
+
key = resolve_api_key(env_var=env_var)
|
|
114
|
+
if not key:
|
|
115
|
+
raise KeyError(f"api key not found for {name}: set env {env_var} or auth.api_key in settings.json")
|
|
116
|
+
return key
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def clear_cache() -> None:
|
|
120
|
+
_SETTINGS_CACHE.clear()
|
|
121
|
+
logger.debug("config cache cleared")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- dotenv env-file loading (issue #47, config-conventions §1) ---
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_dotenv_line(line: str) -> tuple[str, str] | None:
|
|
128
|
+
stripped = line.strip()
|
|
129
|
+
if not stripped or stripped.startswith("#"):
|
|
130
|
+
return None
|
|
131
|
+
if stripped.startswith("export "):
|
|
132
|
+
stripped = stripped[len("export ") :].lstrip()
|
|
133
|
+
key, sep, value = stripped.partition("=")
|
|
134
|
+
if not sep or not key.strip() or not key.strip().replace("_", "a").replace("-", "a").isalnum():
|
|
135
|
+
logger.debug("load_dotenv: skipping malformed line: %s", line.strip()[:60])
|
|
136
|
+
return None
|
|
137
|
+
key = key.strip()
|
|
138
|
+
value = value.strip()
|
|
139
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
140
|
+
value = value[1:-1]
|
|
141
|
+
else:
|
|
142
|
+
# strip trailing inline comment on unquoted values
|
|
143
|
+
if " #" in value:
|
|
144
|
+
value = value.split(" #", 1)[0].rstrip()
|
|
145
|
+
return key, value
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_dotenv(path: str | Path | None = None, *, override: bool = False) -> dict[str, str]:
|
|
149
|
+
# Load KEY=VALUE pairs from a dotenv file into os.environ.
|
|
150
|
+
# Resolution: explicit path arg -> ENV_FILE env var -> no-op (empty dict).
|
|
151
|
+
# Default does NOT overwrite variables already set in the environment
|
|
152
|
+
# (standard dotenv semantics); override=True forces them.
|
|
153
|
+
if path is None:
|
|
154
|
+
env_file = os.environ.get("ENV_FILE", "")
|
|
155
|
+
if not env_file:
|
|
156
|
+
logger.debug("load_dotenv: no path arg and ENV_FILE unset, no-op")
|
|
157
|
+
return {}
|
|
158
|
+
path = env_file
|
|
159
|
+
p = Path(path)
|
|
160
|
+
try:
|
|
161
|
+
text = p.read_text(encoding="utf-8")
|
|
162
|
+
except FileNotFoundError:
|
|
163
|
+
logger.warning("load_dotenv: env file not found: %s", p)
|
|
164
|
+
raise
|
|
165
|
+
loaded: dict[str, str] = {}
|
|
166
|
+
for line in text.splitlines():
|
|
167
|
+
parsed = _parse_dotenv_line(line)
|
|
168
|
+
if parsed is None:
|
|
169
|
+
continue
|
|
170
|
+
key, value = parsed
|
|
171
|
+
if not override and os.environ.get(key, "") != "":
|
|
172
|
+
logger.debug("load_dotenv: %s already set, keeping existing value", key)
|
|
173
|
+
continue
|
|
174
|
+
os.environ[key] = value
|
|
175
|
+
loaded[key] = value
|
|
176
|
+
logger.info("load_dotenv: %d vars loaded from %s (override=%s)", len(loaded), p, override)
|
|
177
|
+
return loaded
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --- config-conventions single-point resolution (config-conventions.md §1/§3) ---
|
|
181
|
+
|
|
182
|
+
_DEFAULT_GATEWAY_BASE_URL = "http://127.0.0.1:11432"
|
|
183
|
+
_ANON_WARNED: set[str] = set()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def profile() -> str:
|
|
187
|
+
return os.environ.get("FUSION_PROFILE", "dev")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _resolve_secret(env: str, *, hint: str) -> str:
|
|
191
|
+
key = os.environ.get(env, "")
|
|
192
|
+
if key:
|
|
193
|
+
return key
|
|
194
|
+
if profile() == "prod":
|
|
195
|
+
raise RuntimeError(f"{env} not set — {hint}, see architecture/config-conventions.md")
|
|
196
|
+
if env not in _ANON_WARNED:
|
|
197
|
+
_ANON_WARNED.add(env)
|
|
198
|
+
logger.warning("anonymous dev mode: %s not set (%s)", env, hint)
|
|
199
|
+
return ""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def client_key() -> str:
|
|
203
|
+
for env in ("FUSION_CLIENT_KEY", "FUSION_MLX_API_KEY", "MLX_API_KEY"):
|
|
204
|
+
key = os.environ.get(env, "")
|
|
205
|
+
if key:
|
|
206
|
+
if env != "FUSION_CLIENT_KEY":
|
|
207
|
+
logger.warning("%s is deprecated, set FUSION_CLIENT_KEY instead (config-conventions §6)", env)
|
|
208
|
+
return key
|
|
209
|
+
if profile() == "prod":
|
|
210
|
+
raise RuntimeError(
|
|
211
|
+
"FUSION_CLIENT_KEY not set — obtain from gateway admin API, see architecture/config-conventions.md"
|
|
212
|
+
)
|
|
213
|
+
if "FUSION_CLIENT_KEY" not in _ANON_WARNED:
|
|
214
|
+
_ANON_WARNED.add("FUSION_CLIENT_KEY")
|
|
215
|
+
logger.warning("anonymous dev mode: FUSION_CLIENT_KEY not set (obtain from gateway admin API)")
|
|
216
|
+
return ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def admin_key() -> str:
|
|
220
|
+
return _resolve_secret("FUSION_ADMIN_KEY", hint="obtain from gateway admin API")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def gateway_base_url() -> str:
|
|
224
|
+
url = os.environ.get("FUSION_GATEWAY_BASE_URL", "")
|
|
225
|
+
if url:
|
|
226
|
+
return url
|
|
227
|
+
legacy = os.environ.get("FUSION_GATEWAY_URL", "")
|
|
228
|
+
if legacy:
|
|
229
|
+
logger.warning("FUSION_GATEWAY_URL is deprecated, set FUSION_GATEWAY_BASE_URL instead (config-conventions §6)")
|
|
230
|
+
return legacy
|
|
231
|
+
return _DEFAULT_GATEWAY_BASE_URL
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def service_port(app: str, default: int | None = None) -> int:
|
|
235
|
+
if not app:
|
|
236
|
+
raise ValueError("service_port: app name required")
|
|
237
|
+
short = app.removeprefix("fusion-")
|
|
238
|
+
env = f"FUSION_{short.upper().replace('-', '_')}_PORT"
|
|
239
|
+
raw = os.environ.get(env, "")
|
|
240
|
+
if raw:
|
|
241
|
+
try:
|
|
242
|
+
return int(raw)
|
|
243
|
+
except ValueError as exc:
|
|
244
|
+
raise ValueError(f"{env} is not an integer: {raw!r}") from exc
|
|
245
|
+
if default is not None:
|
|
246
|
+
return default
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"{env} not set and no default given — "
|
|
249
|
+
"register the port in architecture/port-registry.yaml and set the env or pass default="
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def service_data_dir(app: str, default: str | Path | None = None) -> Path:
|
|
254
|
+
if not app:
|
|
255
|
+
raise ValueError("service_data_dir: app name required")
|
|
256
|
+
short = app.removeprefix("fusion-")
|
|
257
|
+
raw = os.environ.get(f"FUSION_{short.upper().replace('-', '_')}_DATA_DIR", "")
|
|
258
|
+
if raw:
|
|
259
|
+
return Path(raw)
|
|
260
|
+
if default is not None:
|
|
261
|
+
return Path(default)
|
|
262
|
+
return Path.home() / f".{short}"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def gateway_admin_key(settings_path: str | Path | None = None) -> str:
|
|
266
|
+
key = os.environ.get("FUSION_GATEWAY_API_KEY", "")
|
|
267
|
+
if key:
|
|
268
|
+
return key
|
|
269
|
+
cfg: dict = {}
|
|
270
|
+
try:
|
|
271
|
+
cfg = load_settings(settings_path)
|
|
272
|
+
except (FileNotFoundError, OSError, ValueError) as exc:
|
|
273
|
+
logger.debug("gateway_admin_key: settings unavailable: %s", exc)
|
|
274
|
+
shell = cfg.get("shell", {})
|
|
275
|
+
if isinstance(shell, dict):
|
|
276
|
+
raw = shell.get("gateway_admin_key", "")
|
|
277
|
+
if raw is None:
|
|
278
|
+
logger.warning("gateway_admin_key: shell.gateway_admin_key is null in settings.json")
|
|
279
|
+
elif not isinstance(raw, str):
|
|
280
|
+
logger.warning("gateway_admin_key: shell.gateway_admin_key not a string, got %s", type(raw).__name__)
|
|
281
|
+
elif raw:
|
|
282
|
+
return raw
|
|
283
|
+
if profile() == "prod":
|
|
284
|
+
raise RuntimeError(
|
|
285
|
+
"FUSION_GATEWAY_API_KEY not set and shell.gateway_admin_key absent — "
|
|
286
|
+
"obtain from gateway admin API, see architecture/config-conventions.md"
|
|
287
|
+
)
|
|
288
|
+
if "FUSION_GATEWAY_API_KEY" not in _ANON_WARNED:
|
|
289
|
+
_ANON_WARNED.add("FUSION_GATEWAY_API_KEY")
|
|
290
|
+
logger.warning("anonymous dev mode: FUSION_GATEWAY_API_KEY not set (obtain from gateway admin API)")
|
|
291
|
+
return ""
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
_VISUAL_TYPES = (
|
|
9
|
+
"array_grid",
|
|
10
|
+
"tape_diagram",
|
|
11
|
+
"geometry_2d",
|
|
12
|
+
"isometric_3d",
|
|
13
|
+
"track_timeline",
|
|
14
|
+
"bucket_divider",
|
|
15
|
+
"data_chart",
|
|
16
|
+
"flow_card",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_FALLBACK_TYPES = ("tape_diagram", "flow_card")
|
|
20
|
+
|
|
21
|
+
SOCRATIC_DSL_V3_SCHEMA: dict = {
|
|
22
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
23
|
+
"title": "Socratic DSL v3.0",
|
|
24
|
+
"description": "Canonical JSON Schema for the Fusion Socratic DSL v3.0 (k12-train-model spec).",
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["meta", "parameters", "entities", "pipeline"],
|
|
27
|
+
"additionalProperties": False,
|
|
28
|
+
"properties": {
|
|
29
|
+
"meta": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"required": ["id", "grade", "topic", "visual_type", "fallback_type"],
|
|
32
|
+
"additionalProperties": False,
|
|
33
|
+
"properties": {
|
|
34
|
+
"id": {"type": "string"},
|
|
35
|
+
"grade": {"type": "number"},
|
|
36
|
+
"topic": {"type": "string"},
|
|
37
|
+
"visual_type": {"type": "string", "enum": list(_VISUAL_TYPES)},
|
|
38
|
+
"fallback_type": {"type": "string", "enum": list(_FALLBACK_TYPES)},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
"parameters": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"additionalProperties": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"required": ["label", "value", "unit"],
|
|
46
|
+
"additionalProperties": False,
|
|
47
|
+
"properties": {
|
|
48
|
+
"label": {"type": "string"},
|
|
49
|
+
"value": {"type": "number"},
|
|
50
|
+
"unit": {"type": "string"},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
"entities": {
|
|
55
|
+
"type": "array",
|
|
56
|
+
"items": {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"required": ["id", "type", "label", "properties"],
|
|
59
|
+
"additionalProperties": False,
|
|
60
|
+
"properties": {
|
|
61
|
+
"id": {"type": "string"},
|
|
62
|
+
"type": {"type": "string"},
|
|
63
|
+
"label": {"type": "string"},
|
|
64
|
+
"properties": {"type": "object"},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
"pipeline": {
|
|
69
|
+
"type": "array",
|
|
70
|
+
"items": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"required": ["step", "title", "formula", "eval", "result_unit", "visual_state"],
|
|
73
|
+
"additionalProperties": False,
|
|
74
|
+
"properties": {
|
|
75
|
+
"step": {"type": "number"},
|
|
76
|
+
"title": {"type": "string"},
|
|
77
|
+
"formula": {"type": "string"},
|
|
78
|
+
"eval": {"type": "string"},
|
|
79
|
+
"result_unit": {"type": "string"},
|
|
80
|
+
"visual_state": {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"required": ["progress_range", "active_entities", "action"],
|
|
83
|
+
"additionalProperties": False,
|
|
84
|
+
"properties": {
|
|
85
|
+
"progress_range": {
|
|
86
|
+
"type": "array",
|
|
87
|
+
"items": {"type": "number"},
|
|
88
|
+
"minItems": 2,
|
|
89
|
+
"maxItems": 2,
|
|
90
|
+
},
|
|
91
|
+
"active_entities": {"type": "array", "items": {"type": "string"}},
|
|
92
|
+
"action": {"type": "string"},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_VALIDATOR = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _get_validator():
|
|
105
|
+
global _VALIDATOR
|
|
106
|
+
if _VALIDATOR is None:
|
|
107
|
+
try:
|
|
108
|
+
from jsonschema import Draft202012Validator
|
|
109
|
+
except ImportError as exc:
|
|
110
|
+
raise ImportError(
|
|
111
|
+
"dsl_schema requires jsonschema: pip install 'fusion-core[schema]' or pip install jsonschema"
|
|
112
|
+
) from exc
|
|
113
|
+
_VALIDATOR = Draft202012Validator(SOCRATIC_DSL_V3_SCHEMA)
|
|
114
|
+
logger.debug("dsl_schema validator initialized (jsonschema Draft202012)")
|
|
115
|
+
return _VALIDATOR
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_dsl(dsl_dict: dict) -> bool:
|
|
119
|
+
if not isinstance(dsl_dict, dict):
|
|
120
|
+
logger.warning("validate_dsl expects dict, got %s", type(dsl_dict).__name__)
|
|
121
|
+
return False
|
|
122
|
+
validator = _get_validator()
|
|
123
|
+
errors = sorted(validator.iter_errors(dsl_dict), key=lambda e: list(e.path))
|
|
124
|
+
if not errors:
|
|
125
|
+
return True
|
|
126
|
+
first = errors[0]
|
|
127
|
+
path = ".".join(str(p) for p in first.path) or "<root>"
|
|
128
|
+
logger.warning("dsl validation failed at %s: %s", path, first.message)
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def validate_dsl_str(dsl_str: str) -> tuple[bool, str]:
|
|
133
|
+
if not isinstance(dsl_str, str):
|
|
134
|
+
return False, f"expected str, got {type(dsl_str).__name__}"
|
|
135
|
+
if not dsl_str.strip():
|
|
136
|
+
return False, "empty input"
|
|
137
|
+
try:
|
|
138
|
+
parsed = json.loads(dsl_str)
|
|
139
|
+
except json.JSONDecodeError as exc:
|
|
140
|
+
logger.debug("validate_dsl_str json parse fail: %s", exc)
|
|
141
|
+
return False, f"json parse error: {exc}"
|
|
142
|
+
if not isinstance(parsed, dict):
|
|
143
|
+
return False, f"expected dict, got {type(parsed).__name__}"
|
|
144
|
+
validator = _get_validator()
|
|
145
|
+
errors = sorted(validator.iter_errors(parsed), key=lambda e: list(e.path))
|
|
146
|
+
if not errors:
|
|
147
|
+
return True, ""
|
|
148
|
+
first = errors[0]
|
|
149
|
+
path = ".".join(str(p) for p in first.path) or "<root>"
|
|
150
|
+
return False, f"{path}: {first.message}"
|