atomadic-forge 0.3.2__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.
- atomadic_forge/__init__.py +12 -0
- atomadic_forge/__main__.py +5 -0
- atomadic_forge/a0_qk_constants/__init__.py +1 -0
- atomadic_forge/a0_qk_constants/agent_plan_schema.py +120 -0
- atomadic_forge/a0_qk_constants/commandsmith_types.py +49 -0
- atomadic_forge/a0_qk_constants/config_defaults.py +38 -0
- atomadic_forge/a0_qk_constants/emergent_types.py +77 -0
- atomadic_forge/a0_qk_constants/error_codes.py +296 -0
- atomadic_forge/a0_qk_constants/forge_types.py +89 -0
- atomadic_forge/a0_qk_constants/gen_language.py +116 -0
- atomadic_forge/a0_qk_constants/lang_extensions.py +150 -0
- atomadic_forge/a0_qk_constants/policy_schema.py +48 -0
- atomadic_forge/a0_qk_constants/receipt_schema.py +311 -0
- atomadic_forge/a0_qk_constants/roi_constants.py +96 -0
- atomadic_forge/a0_qk_constants/semantic_types.py +61 -0
- atomadic_forge/a0_qk_constants/sidecar_schema.py +81 -0
- atomadic_forge/a0_qk_constants/synergy_types.py +62 -0
- atomadic_forge/a0_qk_constants/tier_names.py +47 -0
- atomadic_forge/a1_at_functions/__init__.py +1 -0
- atomadic_forge/a1_at_functions/agent_context_pack.py +193 -0
- atomadic_forge/a1_at_functions/agent_memory.py +139 -0
- atomadic_forge/a1_at_functions/agent_plan_emitter.py +324 -0
- atomadic_forge/a1_at_functions/agent_summary.py +277 -0
- atomadic_forge/a1_at_functions/body_extractor.py +306 -0
- atomadic_forge/a1_at_functions/card_renderer.py +210 -0
- atomadic_forge/a1_at_functions/certify_checks.py +445 -0
- atomadic_forge/a1_at_functions/chat_context.py +170 -0
- atomadic_forge/a1_at_functions/cherry_pick.py +71 -0
- atomadic_forge/a1_at_functions/classify_tier.py +115 -0
- atomadic_forge/a1_at_functions/commandsmith_discover.py +167 -0
- atomadic_forge/a1_at_functions/commandsmith_render.py +267 -0
- atomadic_forge/a1_at_functions/compiler_feedback.py +94 -0
- atomadic_forge/a1_at_functions/compliance_checker.py +228 -0
- atomadic_forge/a1_at_functions/config_io.py +68 -0
- atomadic_forge/a1_at_functions/cs1_renderer.py +588 -0
- atomadic_forge/a1_at_functions/doc_synthesizer.py +205 -0
- atomadic_forge/a1_at_functions/emergent_compose.py +192 -0
- atomadic_forge/a1_at_functions/emergent_rank.py +116 -0
- atomadic_forge/a1_at_functions/emergent_signature_extract.py +242 -0
- atomadic_forge/a1_at_functions/emergent_synthesize.py +88 -0
- atomadic_forge/a1_at_functions/enforce_planner.py +208 -0
- atomadic_forge/a1_at_functions/error_hints.py +105 -0
- atomadic_forge/a1_at_functions/evolution_log.py +94 -0
- atomadic_forge/a1_at_functions/forge_feedback.py +433 -0
- atomadic_forge/a1_at_functions/generation_quality.py +322 -0
- atomadic_forge/a1_at_functions/import_repair.py +211 -0
- atomadic_forge/a1_at_functions/import_smoke.py +102 -0
- atomadic_forge/a1_at_functions/js_parser.py +539 -0
- atomadic_forge/a1_at_functions/lineage_chain.py +144 -0
- atomadic_forge/a1_at_functions/lineage_reader.py +107 -0
- atomadic_forge/a1_at_functions/llm_client.py +554 -0
- atomadic_forge/a1_at_functions/local_signer.py +134 -0
- atomadic_forge/a1_at_functions/lsp_protocol.py +379 -0
- atomadic_forge/a1_at_functions/manifest_diff.py +314 -0
- atomadic_forge/a1_at_functions/mcp_protocol.py +1066 -0
- atomadic_forge/a1_at_functions/patch_scorer.py +267 -0
- atomadic_forge/a1_at_functions/plan_adapter.py +75 -0
- atomadic_forge/a1_at_functions/policy_loader.py +107 -0
- atomadic_forge/a1_at_functions/preflight_change.py +227 -0
- atomadic_forge/a1_at_functions/progress_reporter.py +81 -0
- atomadic_forge/a1_at_functions/provider_detect.py +157 -0
- atomadic_forge/a1_at_functions/provider_resolver.py +48 -0
- atomadic_forge/a1_at_functions/receipt_emitter.py +291 -0
- atomadic_forge/a1_at_functions/recipes.py +186 -0
- atomadic_forge/a1_at_functions/repo_explainer.py +124 -0
- atomadic_forge/a1_at_functions/roi_calculator.py +265 -0
- atomadic_forge/a1_at_functions/rollback_planner.py +147 -0
- atomadic_forge/a1_at_functions/sbom_emitter.py +155 -0
- atomadic_forge/a1_at_functions/scaffold_js.py +55 -0
- atomadic_forge/a1_at_functions/scaffold_pyproject.py +62 -0
- atomadic_forge/a1_at_functions/scaffold_starter.py +94 -0
- atomadic_forge/a1_at_functions/scout_walk.py +309 -0
- atomadic_forge/a1_at_functions/sidecar_parser.py +161 -0
- atomadic_forge/a1_at_functions/sidecar_validator.py +202 -0
- atomadic_forge/a1_at_functions/stub_detector.py +158 -0
- atomadic_forge/a1_at_functions/synergy_detect.py +166 -0
- atomadic_forge/a1_at_functions/synergy_render.py +252 -0
- atomadic_forge/a1_at_functions/synergy_surface_extract.py +163 -0
- atomadic_forge/a1_at_functions/test_runner.py +196 -0
- atomadic_forge/a1_at_functions/test_selector.py +122 -0
- atomadic_forge/a1_at_functions/tier_init_rebuild.py +122 -0
- atomadic_forge/a1_at_functions/tool_composer.py +130 -0
- atomadic_forge/a1_at_functions/transcript_log.py +70 -0
- atomadic_forge/a1_at_functions/wire_check.py +260 -0
- atomadic_forge/a2_mo_composites/__init__.py +1 -0
- atomadic_forge/a2_mo_composites/lineage_chain_store.py +122 -0
- atomadic_forge/a2_mo_composites/manifest_store.py +46 -0
- atomadic_forge/a2_mo_composites/plan_store.py +164 -0
- atomadic_forge/a2_mo_composites/receipt_signer.py +231 -0
- atomadic_forge/a3_og_features/__init__.py +1 -0
- atomadic_forge/a3_og_features/commandsmith_feature.py +267 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/__init__.py +3 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a0_qk_constants/__init__.py +4 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a1_at_functions/__init__.py +14 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/conftest.py +10 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/test_mixed.py +18 -0
- atomadic_forge/a3_og_features/demo_runner.py +502 -0
- atomadic_forge/a3_og_features/emergent_feature.py +95 -0
- atomadic_forge/a3_og_features/emergent_pipeline_integration.py +154 -0
- atomadic_forge/a3_og_features/forge_enforce.py +107 -0
- atomadic_forge/a3_og_features/forge_evolve.py +176 -0
- atomadic_forge/a3_og_features/forge_loop.py +528 -0
- atomadic_forge/a3_og_features/forge_pipeline.py +295 -0
- atomadic_forge/a3_og_features/forge_plan_apply.py +222 -0
- atomadic_forge/a3_og_features/lsp_server.py +98 -0
- atomadic_forge/a3_og_features/mcp_server.py +160 -0
- atomadic_forge/a3_og_features/setup_wizard.py +337 -0
- atomadic_forge/a3_og_features/synergy_feature.py +65 -0
- atomadic_forge/a4_sy_orchestration/__init__.py +1 -0
- atomadic_forge/a4_sy_orchestration/cli.py +1284 -0
- atomadic_forge/commands/__init__.py +1 -0
- atomadic_forge/commands/_registry.py +36 -0
- atomadic_forge/commands/audit.py +142 -0
- atomadic_forge/commands/chat.py +133 -0
- atomadic_forge/commands/commandsmith.py +178 -0
- atomadic_forge/commands/config_cmd.py +145 -0
- atomadic_forge/commands/demo.py +142 -0
- atomadic_forge/commands/emergent.py +124 -0
- atomadic_forge/commands/emergent_then_synergy.py +70 -0
- atomadic_forge/commands/evolve.py +122 -0
- atomadic_forge/commands/evolve_then_iterate.py +70 -0
- atomadic_forge/commands/feature_then_emergent.py +111 -0
- atomadic_forge/commands/iterate.py +140 -0
- atomadic_forge/commands/synergy.py +96 -0
- atomadic_forge/commands/synergy_then_emergent.py +70 -0
- atomadic_forge-0.3.2.dist-info/METADATA +471 -0
- atomadic_forge-0.3.2.dist-info/RECORD +131 -0
- atomadic_forge-0.3.2.dist-info/WHEEL +5 -0
- atomadic_forge-0.3.2.dist-info/entry_points.txt +3 -0
- atomadic_forge-0.3.2.dist-info/licenses/LICENSE +15 -0
- atomadic_forge-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Tier a1 — pure readers for .atomadic-forge lineage.jsonl + manifests.
|
|
2
|
+
|
|
3
|
+
Audit pain point (Lane D1): every ``--apply`` writes to lineage.jsonl,
|
|
4
|
+
but Forge ships no verb to query that file. Auditors and developers
|
|
5
|
+
have to ``cat`` and ``jq`` it. This module gives the CLI a clean
|
|
6
|
+
interface to surface what's there.
|
|
7
|
+
|
|
8
|
+
Pure: read-only, returns structured dicts. Side-effecting verbs
|
|
9
|
+
(``trend``, ``replay``) require lineage-shape extension and live in
|
|
10
|
+
their own lane; this module supports the inspection surface today.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_DEFAULT_DIRNAME = ".atomadic-forge"
|
|
18
|
+
_LINEAGE_FILE = "lineage.jsonl"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def lineage_path(project_root: Path, *, dirname: str = _DEFAULT_DIRNAME) -> Path:
|
|
22
|
+
"""Return the absolute path of the lineage log under ``project_root``."""
|
|
23
|
+
return Path(project_root).resolve() / dirname / _LINEAGE_FILE
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_lineage(
|
|
27
|
+
project_root: Path,
|
|
28
|
+
*,
|
|
29
|
+
dirname: str = _DEFAULT_DIRNAME,
|
|
30
|
+
last: int | None = None,
|
|
31
|
+
) -> list[dict]:
|
|
32
|
+
"""Return lineage entries newest-last.
|
|
33
|
+
|
|
34
|
+
``last`` (optional): when set, return only the most recent N entries.
|
|
35
|
+
Malformed lines are skipped silently — the lineage log is append-only
|
|
36
|
+
and a corrupt line should not break inspection of the rest.
|
|
37
|
+
"""
|
|
38
|
+
log = lineage_path(project_root, dirname=dirname)
|
|
39
|
+
if not log.exists():
|
|
40
|
+
return []
|
|
41
|
+
entries: list[dict] = []
|
|
42
|
+
for line in log.read_text(encoding="utf-8").splitlines():
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if not line:
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
entry = json.loads(line)
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
continue
|
|
50
|
+
if isinstance(entry, dict):
|
|
51
|
+
entries.append(entry)
|
|
52
|
+
if last is not None and last >= 0:
|
|
53
|
+
entries = entries[-last:]
|
|
54
|
+
return entries
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def list_artifacts(
|
|
58
|
+
project_root: Path,
|
|
59
|
+
*,
|
|
60
|
+
dirname: str = _DEFAULT_DIRNAME,
|
|
61
|
+
) -> list[dict]:
|
|
62
|
+
"""Return one summary entry per distinct artifact name.
|
|
63
|
+
|
|
64
|
+
Each summary names the artifact, its run count, the latest write
|
|
65
|
+
timestamp, and the relative path. Sorted by latest write descending.
|
|
66
|
+
"""
|
|
67
|
+
by_name: dict[str, dict] = {}
|
|
68
|
+
for entry in read_lineage(project_root, dirname=dirname):
|
|
69
|
+
name = entry.get("artifact", "")
|
|
70
|
+
if not name:
|
|
71
|
+
continue
|
|
72
|
+
existing = by_name.get(name)
|
|
73
|
+
if existing is None:
|
|
74
|
+
by_name[name] = {
|
|
75
|
+
"artifact": name,
|
|
76
|
+
"run_count": 1,
|
|
77
|
+
"latest_ts_utc": entry.get("ts_utc", ""),
|
|
78
|
+
"path": entry.get("path", ""),
|
|
79
|
+
}
|
|
80
|
+
else:
|
|
81
|
+
existing["run_count"] += 1
|
|
82
|
+
ts = entry.get("ts_utc", "")
|
|
83
|
+
if ts > existing["latest_ts_utc"]:
|
|
84
|
+
existing["latest_ts_utc"] = ts
|
|
85
|
+
existing["path"] = entry.get("path", "")
|
|
86
|
+
return sorted(by_name.values(),
|
|
87
|
+
key=lambda d: d["latest_ts_utc"], reverse=True)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_manifest(
|
|
91
|
+
project_root: Path,
|
|
92
|
+
artifact: str,
|
|
93
|
+
*,
|
|
94
|
+
dirname: str = _DEFAULT_DIRNAME,
|
|
95
|
+
) -> dict | None:
|
|
96
|
+
"""Return the saved JSON manifest for ``artifact`` (e.g. 'scout',
|
|
97
|
+
'cherry', 'wire', 'certify'). Returns None when no such manifest
|
|
98
|
+
has been written.
|
|
99
|
+
"""
|
|
100
|
+
candidate = Path(project_root).resolve() / dirname / f"{artifact}.json"
|
|
101
|
+
if not candidate.exists():
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
data = json.loads(candidate.read_text(encoding="utf-8"))
|
|
105
|
+
except (OSError, json.JSONDecodeError):
|
|
106
|
+
return None
|
|
107
|
+
return data if isinstance(data, dict) else None
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Tier a1 — pluggable LLM client interface.
|
|
2
|
+
|
|
3
|
+
Forge is the architecture substrate. An LLM is the generation engine.
|
|
4
|
+
This module defines the contract between the two: a single ``call(prompt,
|
|
5
|
+
system) -> str`` interface, plus several ready-to-use implementations:
|
|
6
|
+
|
|
7
|
+
* :class:`AnthropicClient` — Claude. Reads ``ANTHROPIC_API_KEY``.
|
|
8
|
+
* :class:`OpenAIClient` — GPT. Reads ``OPENAI_API_KEY``.
|
|
9
|
+
* :class:`GeminiClient` — Google AI Studio (free tier). Reads ``GEMINI_API_KEY``.
|
|
10
|
+
* :class:`AAAANexusClient` — AAAA-Nexus inference (Cloudflare-Workers-AI
|
|
11
|
+
upstream, BitNet/HELIX wrappers, built-in hallucination guard). Reads
|
|
12
|
+
``AAAA_NEXUS_API_KEY``. Each call is billed at $0.015 by the upstream
|
|
13
|
+
Nexus account — usage drives revenue while passing through the
|
|
14
|
+
anti-hallucination trust gate on every response.
|
|
15
|
+
* :class:`OllamaClient` — local. Talks to ``http://localhost:11434``.
|
|
16
|
+
* :class:`StubLLMClient` — deterministic, used by tests and dry-runs.
|
|
17
|
+
|
|
18
|
+
The client is intentionally minimal — Forge feeds it structured feedback
|
|
19
|
+
from wire/certify/emergent and asks for code in return. The LLM's job is
|
|
20
|
+
generation; Forge's job is keeping that generation in line with the
|
|
21
|
+
5-tier law.
|
|
22
|
+
|
|
23
|
+
Free-tier note: ``gemini-2.5-flash`` and ``gemini-2.0-flash`` are free at
|
|
24
|
+
Google AI Studio (15 RPM, ~1500 RPD). Get a key at
|
|
25
|
+
https://aistudio.google.com/apikey then ``export GEMINI_API_KEY=…``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
from typing import Protocol
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LLMClient(Protocol):
|
|
39
|
+
"""Minimal contract every Forge LLM backend implements."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
|
|
43
|
+
def call(self, prompt: str, *, system: str = "",
|
|
44
|
+
max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _env_float(name: str, default: float) -> float:
|
|
49
|
+
raw = os.environ.get(name)
|
|
50
|
+
if raw is None or raw == "":
|
|
51
|
+
return default
|
|
52
|
+
try:
|
|
53
|
+
return float(raw)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _env_int(name: str, default: int | None) -> int | None:
|
|
59
|
+
raw = os.environ.get(name)
|
|
60
|
+
if raw is None or raw == "":
|
|
61
|
+
return default
|
|
62
|
+
try:
|
|
63
|
+
return int(raw)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return default
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class StubLLMClient:
|
|
69
|
+
"""Deterministic stub for tests + offline runs.
|
|
70
|
+
|
|
71
|
+
Configure with a ``responder(prompt, system) -> str`` callable, or pass
|
|
72
|
+
a list of canned responses for sequential calls.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
name = "stub"
|
|
76
|
+
|
|
77
|
+
def __init__(self, *, responder: Callable[[str, str], str] | None = None,
|
|
78
|
+
canned: list[str] | None = None):
|
|
79
|
+
self._responder = responder
|
|
80
|
+
self._canned = list(canned or [])
|
|
81
|
+
self._calls = 0
|
|
82
|
+
|
|
83
|
+
def call(self, prompt: str, *, system: str = "",
|
|
84
|
+
max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
|
85
|
+
self._calls += 1
|
|
86
|
+
if self._responder is not None:
|
|
87
|
+
return self._responder(prompt, system)
|
|
88
|
+
if self._canned:
|
|
89
|
+
return self._canned.pop(0) if self._canned else ""
|
|
90
|
+
return f"# stub response (call #{self._calls})\n"
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def calls(self) -> int:
|
|
94
|
+
return self._calls
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AnthropicClient:
|
|
98
|
+
"""Claude via the Messages API."""
|
|
99
|
+
|
|
100
|
+
name = "anthropic"
|
|
101
|
+
|
|
102
|
+
def __init__(self, *, model: str = "claude-3-5-sonnet-latest",
|
|
103
|
+
api_key_env: str = "ANTHROPIC_API_KEY"):
|
|
104
|
+
self.model = model
|
|
105
|
+
self._api_key_env = api_key_env
|
|
106
|
+
|
|
107
|
+
def call(self, prompt: str, *, system: str = "",
|
|
108
|
+
max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
|
109
|
+
api_key = os.environ.get(self._api_key_env, "")
|
|
110
|
+
if not api_key:
|
|
111
|
+
raise RuntimeError(
|
|
112
|
+
f"{self._api_key_env} not set — cannot call Anthropic API"
|
|
113
|
+
)
|
|
114
|
+
body = json.dumps({
|
|
115
|
+
"model": self.model,
|
|
116
|
+
"max_tokens": max_tokens,
|
|
117
|
+
"temperature": temperature,
|
|
118
|
+
"system": system or "You are a coding assistant.",
|
|
119
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
120
|
+
}).encode("utf-8")
|
|
121
|
+
req = urllib.request.Request(
|
|
122
|
+
"https://api.anthropic.com/v1/messages",
|
|
123
|
+
data=body,
|
|
124
|
+
headers={
|
|
125
|
+
"x-api-key": api_key,
|
|
126
|
+
"anthropic-version": "2023-06-01",
|
|
127
|
+
"content-type": "application/json",
|
|
128
|
+
},
|
|
129
|
+
method="POST",
|
|
130
|
+
)
|
|
131
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
132
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
133
|
+
# Concatenate text blocks; ignore tool-use blocks for this minimal client.
|
|
134
|
+
return "".join(b.get("text", "") for b in data.get("content", [])
|
|
135
|
+
if b.get("type") == "text")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class OpenAIClient:
|
|
139
|
+
"""GPT via the Chat Completions API."""
|
|
140
|
+
|
|
141
|
+
name = "openai"
|
|
142
|
+
|
|
143
|
+
def __init__(self, *, model: str = "gpt-4o-mini",
|
|
144
|
+
api_key_env: str = "OPENAI_API_KEY"):
|
|
145
|
+
self.model = model
|
|
146
|
+
self._api_key_env = api_key_env
|
|
147
|
+
|
|
148
|
+
def call(self, prompt: str, *, system: str = "",
|
|
149
|
+
max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
|
150
|
+
api_key = os.environ.get(self._api_key_env, "")
|
|
151
|
+
if not api_key:
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
f"{self._api_key_env} not set — cannot call OpenAI API"
|
|
154
|
+
)
|
|
155
|
+
messages = []
|
|
156
|
+
if system:
|
|
157
|
+
messages.append({"role": "system", "content": system})
|
|
158
|
+
messages.append({"role": "user", "content": prompt})
|
|
159
|
+
body = json.dumps({
|
|
160
|
+
"model": self.model,
|
|
161
|
+
"max_tokens": max_tokens,
|
|
162
|
+
"temperature": temperature,
|
|
163
|
+
"messages": messages,
|
|
164
|
+
}).encode("utf-8")
|
|
165
|
+
req = urllib.request.Request(
|
|
166
|
+
"https://api.openai.com/v1/chat/completions",
|
|
167
|
+
data=body,
|
|
168
|
+
headers={
|
|
169
|
+
"Authorization": f"Bearer {api_key}",
|
|
170
|
+
"Content-Type": "application/json",
|
|
171
|
+
},
|
|
172
|
+
method="POST",
|
|
173
|
+
)
|
|
174
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
175
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
176
|
+
choices = data.get("choices") or []
|
|
177
|
+
return choices[0]["message"]["content"] if choices else ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class GeminiClient:
|
|
181
|
+
"""Google Gemini via the Generative Language API.
|
|
182
|
+
|
|
183
|
+
Free-tier models confirmed working (verified live during dev):
|
|
184
|
+
* ``gemini-2.5-flash`` — high-quality default (free tier)
|
|
185
|
+
* ``gemini-2.0-flash`` — older, more stable on free tier
|
|
186
|
+
* ``gemini-2.5-pro`` — better reasoning; check tier in AI Studio
|
|
187
|
+
* ``gemini-3.1-pro-preview`` — newest; check tier in AI Studio
|
|
188
|
+
|
|
189
|
+
Override the default via ``FORGE_GEMINI_MODEL=<id>`` env var. Concrete
|
|
190
|
+
rate limits live on your AI Studio dashboard:
|
|
191
|
+
https://aistudio.google.com/rate-limit
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
name = "gemini"
|
|
195
|
+
|
|
196
|
+
def __init__(self, *, model: str = "gemini-2.5-flash",
|
|
197
|
+
api_key_env: str = "GEMINI_API_KEY"):
|
|
198
|
+
self.model = model
|
|
199
|
+
self._api_key_env = api_key_env
|
|
200
|
+
|
|
201
|
+
def call(self, prompt: str, *, system: str = "",
|
|
202
|
+
max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
|
203
|
+
api_key = (os.environ.get(self._api_key_env)
|
|
204
|
+
or os.environ.get("GOOGLE_API_KEY")
|
|
205
|
+
or "")
|
|
206
|
+
if not api_key:
|
|
207
|
+
raise RuntimeError(
|
|
208
|
+
f"{self._api_key_env} (or GOOGLE_API_KEY) not set — "
|
|
209
|
+
"get a free key at https://aistudio.google.com/apikey"
|
|
210
|
+
)
|
|
211
|
+
body_dict: dict[str, object] = {
|
|
212
|
+
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
|
|
213
|
+
"generationConfig": {
|
|
214
|
+
"maxOutputTokens": max_tokens,
|
|
215
|
+
"temperature": temperature,
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
if system:
|
|
219
|
+
body_dict["systemInstruction"] = {"parts": [{"text": system}]}
|
|
220
|
+
body = json.dumps(body_dict).encode("utf-8")
|
|
221
|
+
url = (f"https://generativelanguage.googleapis.com/v1beta/models/"
|
|
222
|
+
f"{self.model}:generateContent?key={api_key}")
|
|
223
|
+
req = urllib.request.Request(
|
|
224
|
+
url,
|
|
225
|
+
data=body,
|
|
226
|
+
headers={"content-type": "application/json"},
|
|
227
|
+
method="POST",
|
|
228
|
+
)
|
|
229
|
+
last_error: Exception | None = None
|
|
230
|
+
for attempt in range(3):
|
|
231
|
+
try:
|
|
232
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
233
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
234
|
+
break
|
|
235
|
+
except urllib.error.HTTPError as exc:
|
|
236
|
+
detail = exc.read().decode("utf-8", errors="replace")[:400]
|
|
237
|
+
if exc.code == 429:
|
|
238
|
+
raise RuntimeError(
|
|
239
|
+
"Gemini quota exceeded. Either: (1) wait for daily "
|
|
240
|
+
"reset, (2) use a different free key, or (3) fall "
|
|
241
|
+
"back to local generation with `--provider ollama`. "
|
|
242
|
+
f"Detail: {detail[:200]}"
|
|
243
|
+
) from exc
|
|
244
|
+
if exc.code in (500, 502, 503, 504):
|
|
245
|
+
# transient — retry with exponential backoff
|
|
246
|
+
last_error = RuntimeError(f"Gemini {exc.code}: {detail[:200]}")
|
|
247
|
+
import time
|
|
248
|
+
time.sleep(1.5 ** attempt)
|
|
249
|
+
continue
|
|
250
|
+
raise RuntimeError(f"Gemini API error {exc.code}: {detail}") from exc
|
|
251
|
+
else:
|
|
252
|
+
raise last_error or RuntimeError("Gemini API: 3 retries exhausted")
|
|
253
|
+
candidates = data.get("candidates") or []
|
|
254
|
+
if not candidates:
|
|
255
|
+
return ""
|
|
256
|
+
parts = candidates[0].get("content", {}).get("parts", [])
|
|
257
|
+
return "".join(p.get("text", "") for p in parts)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class AAAANexusClient:
|
|
261
|
+
"""AAAA-Nexus inference — Cloudflare-Workers-AI upstream with HELIX guards.
|
|
262
|
+
|
|
263
|
+
Hits ``POST /v1/inference`` on the public Nexus Worker. Every call:
|
|
264
|
+
|
|
265
|
+
* Goes through the anti-hallucination trust gate (returns confidence,
|
|
266
|
+
flagged-bool, trust-floor metadata in ``helix.anti_hallucination``).
|
|
267
|
+
* Is billed at $0.015 against the Nexus account that owns the API
|
|
268
|
+
key, so Forge usage drives Nexus revenue automatically.
|
|
269
|
+
* Falls back internally on the upstream side: AAAA_LLM service
|
|
270
|
+
binding → Cloudflare Workers AI REST. No client-side fallback
|
|
271
|
+
logic needed.
|
|
272
|
+
|
|
273
|
+
Configuration:
|
|
274
|
+
* ``AAAA_NEXUS_API_KEY`` — required Bearer token.
|
|
275
|
+
* ``AAAA_NEXUS_URL`` — override base URL (default: the public
|
|
276
|
+
Atomadic-Tech worker).
|
|
277
|
+
* ``AAAA_NEXUS_WRAPPER`` — choose ``helix-standard`` (default),
|
|
278
|
+
``bitnet-standard``, or any wrapper
|
|
279
|
+
exposed by ``GET /v1/agents/capabilities``.
|
|
280
|
+
|
|
281
|
+
The response shape is OpenAI-compatible — ``choices[0].message.content``
|
|
282
|
+
is the only field Forge reads. HELIX/BitNet metadata (compression,
|
|
283
|
+
confidence, trust floor) is preserved on the raw response but not
|
|
284
|
+
returned by ``call()``; callers wanting that signal can subclass.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
name = "aaaa-nexus"
|
|
288
|
+
|
|
289
|
+
DEFAULT_BASE_URL = "https://aaaa-nexus.atomadictech.workers.dev"
|
|
290
|
+
WRAPPER_PATHS = {
|
|
291
|
+
"helix-standard": "/v1/inference",
|
|
292
|
+
"helix-stream": "/v1/inference/stream",
|
|
293
|
+
"bitnet-standard": "/v1/bitnet/inference",
|
|
294
|
+
"bitnet-stream": "/v1/bitnet/inference/stream",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
def __init__(self, *, base_url: str | None = None,
|
|
298
|
+
api_key_env: str = "AAAA_NEXUS_API_KEY",
|
|
299
|
+
wrapper: str | None = None):
|
|
300
|
+
self.base_url = (base_url
|
|
301
|
+
or os.environ.get("AAAA_NEXUS_URL")
|
|
302
|
+
or self.DEFAULT_BASE_URL).rstrip("/")
|
|
303
|
+
self._api_key_env = api_key_env
|
|
304
|
+
self.wrapper = (wrapper
|
|
305
|
+
or os.environ.get("AAAA_NEXUS_WRAPPER")
|
|
306
|
+
or "helix-standard")
|
|
307
|
+
if self.wrapper not in self.WRAPPER_PATHS:
|
|
308
|
+
raise ValueError(
|
|
309
|
+
f"unknown AAAA-Nexus wrapper: {self.wrapper!r} — expected "
|
|
310
|
+
f"one of {list(self.WRAPPER_PATHS)}"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def call(self, prompt: str, *, system: str = "",
|
|
314
|
+
max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
|
315
|
+
api_key = os.environ.get(self._api_key_env, "")
|
|
316
|
+
if not api_key:
|
|
317
|
+
raise RuntimeError(
|
|
318
|
+
f"{self._api_key_env} not set — cannot call AAAA-Nexus inference"
|
|
319
|
+
)
|
|
320
|
+
messages = []
|
|
321
|
+
if system:
|
|
322
|
+
messages.append({"role": "system", "content": system})
|
|
323
|
+
messages.append({"role": "user", "content": prompt})
|
|
324
|
+
# The Nexus inference endpoint validates the body strictly — only
|
|
325
|
+
# ``messages`` is on the public schema today, extra fields like
|
|
326
|
+
# ``max_tokens`` / ``temperature`` get a 403. Keep the payload
|
|
327
|
+
# minimal; upstream picks reasonable defaults for the wrapper.
|
|
328
|
+
body = json.dumps({"messages": messages}).encode("utf-8")
|
|
329
|
+
endpoint = self.base_url + self.WRAPPER_PATHS[self.wrapper]
|
|
330
|
+
req = urllib.request.Request(
|
|
331
|
+
endpoint,
|
|
332
|
+
data=body,
|
|
333
|
+
headers={
|
|
334
|
+
# AAAA-Nexus auth: the storefront worker reads ``X-API-Key``
|
|
335
|
+
# (NOT ``Authorization: Bearer``) and matches against keys
|
|
336
|
+
# registered in NONCE_CACHE KV. Sending only Bearer falls
|
|
337
|
+
# through to the 3-call/day/IP trial path with HTTP 402.
|
|
338
|
+
# We send both headers so the same client also works against
|
|
339
|
+
# any future Bearer-style endpoint without a code change.
|
|
340
|
+
"X-API-Key": api_key,
|
|
341
|
+
"Authorization": f"Bearer {api_key}",
|
|
342
|
+
"Content-Type": "application/json",
|
|
343
|
+
# Cloudflare Worker WAF rejects urllib's default UA
|
|
344
|
+
# ("Python-urllib/3.x") with a 403. Identify ourselves
|
|
345
|
+
# explicitly so the request looks like a real client.
|
|
346
|
+
"User-Agent": "atomadic-forge/0.2 (AAAANexusClient)",
|
|
347
|
+
"Accept": "application/json",
|
|
348
|
+
},
|
|
349
|
+
method="POST",
|
|
350
|
+
)
|
|
351
|
+
with urllib.request.urlopen(req, timeout=180) as resp:
|
|
352
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
353
|
+
# If the upstream's hallucination guard flagged the response with
|
|
354
|
+
# very low confidence, surface it as a runtime warning — the loop
|
|
355
|
+
# will likely produce a poor file emit and we want the signal in
|
|
356
|
+
# logs rather than silently passing through.
|
|
357
|
+
helix = data.get("helix") or {}
|
|
358
|
+
ah = helix.get("anti_hallucination") or {}
|
|
359
|
+
if ah.get("flagged") and ah.get("confidence", 1.0) < 0.3:
|
|
360
|
+
# Soft warning — Forge's certify gate will catch quality issues
|
|
361
|
+
# downstream; we just emit a stderr breadcrumb here.
|
|
362
|
+
import sys as _sys
|
|
363
|
+
print(
|
|
364
|
+
f"[aaaa-nexus] anti-hallucination flagged response "
|
|
365
|
+
f"(confidence={ah.get('confidence'):.3f}, "
|
|
366
|
+
f"trust_floor={ah.get('trust_floor'):.3f})",
|
|
367
|
+
file=_sys.stderr,
|
|
368
|
+
)
|
|
369
|
+
choices = data.get("choices") or []
|
|
370
|
+
if not choices:
|
|
371
|
+
return ""
|
|
372
|
+
return choices[0].get("message", {}).get("content", "") or ""
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class OllamaClient:
|
|
376
|
+
"""Local Ollama daemon."""
|
|
377
|
+
|
|
378
|
+
name = "ollama"
|
|
379
|
+
|
|
380
|
+
def __init__(self, *, model: str = "qwen2.5-coder:7b",
|
|
381
|
+
base_url: str = "http://localhost:11434",
|
|
382
|
+
timeout_s: float | None = None,
|
|
383
|
+
num_predict: int | None = None):
|
|
384
|
+
self.model = model
|
|
385
|
+
self.base_url = base_url.rstrip("/")
|
|
386
|
+
self.timeout_s = timeout_s if timeout_s is not None else _env_float(
|
|
387
|
+
"FORGE_OLLAMA_TIMEOUT", 300.0
|
|
388
|
+
)
|
|
389
|
+
self.num_predict = num_predict if num_predict is not None else _env_int(
|
|
390
|
+
"FORGE_OLLAMA_NUM_PREDICT", None
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def call(self, prompt: str, *, system: str = "",
|
|
394
|
+
max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
|
395
|
+
num_predict = self.num_predict if self.num_predict is not None else max_tokens
|
|
396
|
+
body = json.dumps({
|
|
397
|
+
"model": self.model,
|
|
398
|
+
"stream": False,
|
|
399
|
+
"options": {"temperature": temperature, "num_predict": max(1, num_predict)},
|
|
400
|
+
"system": system,
|
|
401
|
+
"prompt": prompt,
|
|
402
|
+
}).encode("utf-8")
|
|
403
|
+
req = urllib.request.Request(
|
|
404
|
+
f"{self.base_url}/api/generate",
|
|
405
|
+
data=body,
|
|
406
|
+
headers={"content-type": "application/json"},
|
|
407
|
+
method="POST",
|
|
408
|
+
)
|
|
409
|
+
last_exc: Exception | None = None
|
|
410
|
+
for attempt in range(3):
|
|
411
|
+
try:
|
|
412
|
+
with urllib.request.urlopen(req, timeout=self.timeout_s) as resp:
|
|
413
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
414
|
+
return data.get("response", "")
|
|
415
|
+
except urllib.error.HTTPError as exc:
|
|
416
|
+
if exc.code in (500, 502, 503, 504):
|
|
417
|
+
import time
|
|
418
|
+
last_exc = RuntimeError(f"Ollama {exc.code} at {self.base_url}")
|
|
419
|
+
time.sleep(2.0 * (attempt + 1))
|
|
420
|
+
continue
|
|
421
|
+
raise RuntimeError(f"Ollama HTTP {exc.code}: {exc}") from exc
|
|
422
|
+
except urllib.error.URLError as exc:
|
|
423
|
+
raise RuntimeError(
|
|
424
|
+
f"Ollama unreachable at {self.base_url} — "
|
|
425
|
+
f"is `ollama serve` running? Detail: {exc}"
|
|
426
|
+
) from exc
|
|
427
|
+
except TimeoutError as exc:
|
|
428
|
+
raise RuntimeError(
|
|
429
|
+
f"Ollama timed out after {self.timeout_s:g}s while using "
|
|
430
|
+
f"model {self.model!r}. Try a smaller local model, set "
|
|
431
|
+
"`FORGE_OLLAMA_NUM_PREDICT` lower, or raise "
|
|
432
|
+
"`FORGE_OLLAMA_TIMEOUT`."
|
|
433
|
+
) from exc
|
|
434
|
+
raise last_exc or RuntimeError("Ollama: 3 retries exhausted")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class OpenRouterClient:
|
|
438
|
+
"""OpenRouter — routes to 200+ models via OpenAI-compatible API.
|
|
439
|
+
|
|
440
|
+
Reads ``OPENROUTER_API_KEY``. Default model: ``deepseek/deepseek-chat-v3-0324:free``
|
|
441
|
+
(free tier). Override via ``FORGE_OPENROUTER_MODEL`` env var.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
name = "openrouter"
|
|
445
|
+
|
|
446
|
+
BASE_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
447
|
+
|
|
448
|
+
def __init__(self, *, model: str | None = None,
|
|
449
|
+
api_key_env: str = "OPENROUTER_API_KEY"):
|
|
450
|
+
self.model = (model
|
|
451
|
+
or os.environ.get("FORGE_OPENROUTER_MODEL")
|
|
452
|
+
or "google/gemma-3-27b-it:free")
|
|
453
|
+
self._api_key_env = api_key_env
|
|
454
|
+
|
|
455
|
+
def call(self, prompt: str, *, system: str = "",
|
|
456
|
+
max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
|
457
|
+
api_key = os.environ.get(self._api_key_env, "")
|
|
458
|
+
if not api_key:
|
|
459
|
+
raise RuntimeError(
|
|
460
|
+
f"{self._api_key_env} not set — cannot call OpenRouter API"
|
|
461
|
+
)
|
|
462
|
+
messages = []
|
|
463
|
+
if system:
|
|
464
|
+
messages.append({"role": "system", "content": system})
|
|
465
|
+
messages.append({"role": "user", "content": prompt})
|
|
466
|
+
body_dict = {
|
|
467
|
+
"model": self.model,
|
|
468
|
+
"max_tokens": max_tokens,
|
|
469
|
+
"temperature": temperature,
|
|
470
|
+
"messages": messages,
|
|
471
|
+
}
|
|
472
|
+
last_error: Exception | None = None
|
|
473
|
+
for attempt in range(3):
|
|
474
|
+
body = json.dumps(body_dict).encode("utf-8")
|
|
475
|
+
req = urllib.request.Request(
|
|
476
|
+
self.BASE_URL,
|
|
477
|
+
data=body,
|
|
478
|
+
headers={
|
|
479
|
+
"Authorization": f"Bearer {api_key}",
|
|
480
|
+
"Content-Type": "application/json",
|
|
481
|
+
"HTTP-Referer": "https://atomadic.tech",
|
|
482
|
+
"X-Title": "Atomadic Forge",
|
|
483
|
+
},
|
|
484
|
+
method="POST",
|
|
485
|
+
)
|
|
486
|
+
try:
|
|
487
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
488
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
489
|
+
break
|
|
490
|
+
except urllib.error.HTTPError as exc:
|
|
491
|
+
detail = exc.read().decode("utf-8", errors="replace")[:600]
|
|
492
|
+
if exc.code == 400 and "instruction" in detail.lower() and attempt == 0:
|
|
493
|
+
# Model doesn't support system role — fold it into user turn.
|
|
494
|
+
merged = f"[System instructions]\n{system}\n\n[User]\n{prompt}" if system else prompt
|
|
495
|
+
body_dict = {
|
|
496
|
+
"model": self.model,
|
|
497
|
+
"max_tokens": max_tokens,
|
|
498
|
+
"temperature": temperature,
|
|
499
|
+
"messages": [{"role": "user", "content": merged}],
|
|
500
|
+
}
|
|
501
|
+
continue
|
|
502
|
+
if exc.code == 429:
|
|
503
|
+
import time
|
|
504
|
+
last_error = RuntimeError(f"OpenRouter 429: {detail[:200]}")
|
|
505
|
+
time.sleep(2.0 * (attempt + 1))
|
|
506
|
+
continue
|
|
507
|
+
if exc.code in (500, 502, 503, 504):
|
|
508
|
+
import time
|
|
509
|
+
last_error = RuntimeError(f"OpenRouter {exc.code}: {detail[:200]}")
|
|
510
|
+
time.sleep(1.5 ** attempt)
|
|
511
|
+
continue
|
|
512
|
+
raise RuntimeError(f"OpenRouter API error {exc.code}: {detail}") from exc
|
|
513
|
+
else:
|
|
514
|
+
raise last_error or RuntimeError("OpenRouter API: 3 retries exhausted")
|
|
515
|
+
choices = data.get("choices") or []
|
|
516
|
+
return choices[0]["message"]["content"] if choices else ""
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def resolve_default_client() -> LLMClient:
|
|
520
|
+
"""Pick a client based on which env var is set; fall back to stub.
|
|
521
|
+
|
|
522
|
+
Resolution order (sovereign-revenue-first → free-tier → paid → local):
|
|
523
|
+
1. AAAA_NEXUS_API_KEY → AAAANexusClient (every call
|
|
524
|
+
bills upstream Nexus account
|
|
525
|
+
$0.015 with built-in
|
|
526
|
+
hallucination guard)
|
|
527
|
+
2. ANTHROPIC_API_KEY → AnthropicClient
|
|
528
|
+
3. GEMINI_API_KEY / GOOGLE_API_KEY → GeminiClient (free tier)
|
|
529
|
+
4. OPENAI_API_KEY → OpenAIClient
|
|
530
|
+
5. FORGE_OLLAMA=1 → OllamaClient (local, free)
|
|
531
|
+
6. otherwise → StubLLMClient (offline)
|
|
532
|
+
|
|
533
|
+
AAAA-Nexus comes first because it's the only path that simultaneously
|
|
534
|
+
(a) generates revenue for the operator, (b) trust-gates every response
|
|
535
|
+
through the HELIX anti-hallucination layer, and (c) records the call
|
|
536
|
+
in the Nexus audit log automatically.
|
|
537
|
+
"""
|
|
538
|
+
if os.environ.get("AAAA_NEXUS_API_KEY"):
|
|
539
|
+
return AAAANexusClient()
|
|
540
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
541
|
+
return AnthropicClient()
|
|
542
|
+
if os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"):
|
|
543
|
+
return GeminiClient(model=os.environ.get("FORGE_GEMINI_MODEL",
|
|
544
|
+
"gemini-2.5-flash"))
|
|
545
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
546
|
+
return OpenAIClient()
|
|
547
|
+
if os.environ.get("OPENROUTER_API_KEY"):
|
|
548
|
+
return OpenRouterClient()
|
|
549
|
+
if os.environ.get("FORGE_OLLAMA"):
|
|
550
|
+
return OllamaClient(model=os.environ.get("FORGE_OLLAMA_MODEL",
|
|
551
|
+
"qwen2.5-coder:7b"),
|
|
552
|
+
base_url=os.environ.get("OLLAMA_BASE_URL",
|
|
553
|
+
"http://localhost:11434"))
|
|
554
|
+
return StubLLMClient()
|