agents-inc 0.2.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.
- agents_inc/__init__.py +5 -0
- agents_inc/adapters/__init__.py +0 -0
- agents_inc/adapters/base.py +38 -0
- agents_inc/adapters/claude.py +8 -0
- agents_inc/adapters/codex.py +15 -0
- agents_inc/adapters/ollama.py +472 -0
- agents_inc/artifacts.py +170 -0
- agents_inc/bench.py +94 -0
- agents_inc/catalog_refresh.py +173 -0
- agents_inc/config_schema.py +141 -0
- agents_inc/control.py +641 -0
- agents_inc/derivation_store.py +142 -0
- agents_inc/doctor.py +175 -0
- agents_inc/envelope.py +177 -0
- agents_inc/free_caps.py +322 -0
- agents_inc/free_health.py +349 -0
- agents_inc/gateway.py +327 -0
- agents_inc/governance.json +138 -0
- agents_inc/host_ledger.py +183 -0
- agents_inc/hosts/__init__.py +0 -0
- agents_inc/hosts/gen_stubs.py +24 -0
- agents_inc/install/__init__.py +1 -0
- agents_inc/install/bundle.py +104 -0
- agents_inc/install/catalog.py +72 -0
- agents_inc/install/cli.py +179 -0
- agents_inc/install/discovery.py +48 -0
- agents_inc/install/dispatch.py +1287 -0
- agents_inc/install/doctor.py +92 -0
- agents_inc/install/hook.py +153 -0
- agents_inc/install/host_wiring.py +240 -0
- agents_inc/install/mcp_broker.py +361 -0
- agents_inc/install/models_cmd.py +92 -0
- agents_inc/install/paths.py +39 -0
- agents_inc/install/receipt.py +99 -0
- agents_inc/install/runtime.py +460 -0
- agents_inc/install/transaction.py +80 -0
- agents_inc/install/worker_mcp.py +176 -0
- agents_inc/keys.py +64 -0
- agents_inc/ledger.py +824 -0
- agents_inc/lineup_router.py +306 -0
- agents_inc/models.json +406 -0
- agents_inc/pipeline.py +361 -0
- agents_inc/policy.py +178 -0
- agents_inc/protocols.json +197 -0
- agents_inc/registry.py +179 -0
- agents_inc/reviewer.py +125 -0
- agents_inc/router.py +154 -0
- agents_inc/routing.json +35 -0
- agents_inc/schema.py +122 -0
- agents_inc/store.py +520 -0
- agents_inc/usage_join.py +221 -0
- agents_inc/verifier.py +81 -0
- agents_inc-0.2.0.dist-info/METADATA +669 -0
- agents_inc-0.2.0.dist-info/RECORD +60 -0
- agents_inc-0.2.0.dist-info/WHEEL +5 -0
- agents_inc-0.2.0.dist-info/entry_points.txt +2 -0
- agents_inc-0.2.0.dist-info/licenses/LICENSE +145 -0
- agents_inc-0.2.0.dist-info/top_level.txt +2 -0
- workerbees/__init__.py +50 -0
- workerbees/keys.py +49 -0
agents_inc/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# agents_inc/adapters/base.py
|
|
2
|
+
"""Run one tool-free stdin worker. Exit 0 means Returned, never Verified."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import re, subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
_QUOTA = re.compile(r"rate.?limit|quota|usage limit|429|too many requests", re.I)
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class WorkerResult:
|
|
11
|
+
status: str # returned | failed | paused
|
|
12
|
+
output: str
|
|
13
|
+
stderr: str
|
|
14
|
+
exit_code: int
|
|
15
|
+
truncated: bool = False
|
|
16
|
+
|
|
17
|
+
def run_worker(cmd: list[str], stdin_text: str, timeout: int = 300, cwd: str | None = None, max_output_bytes: int = 1_000_000) -> WorkerResult:
|
|
18
|
+
try:
|
|
19
|
+
p = subprocess.run(cmd, input=stdin_text, text=True, capture_output=True, timeout=timeout, cwd=cwd, check=False)
|
|
20
|
+
except FileNotFoundError as e:
|
|
21
|
+
return WorkerResult("failed", "", f"WB_CLI_NOT_FOUND: {e}", 127)
|
|
22
|
+
except subprocess.TimeoutExpired:
|
|
23
|
+
return WorkerResult("failed", "", "timeout", 124)
|
|
24
|
+
|
|
25
|
+
stdout, stderr = p.stdout, p.stderr
|
|
26
|
+
truncated = False
|
|
27
|
+
if len(stdout.encode("utf-8", errors="ignore")) > max_output_bytes:
|
|
28
|
+
stdout = stdout.encode("utf-8", errors="ignore")[:max_output_bytes].decode("utf-8", errors="ignore")
|
|
29
|
+
truncated = True
|
|
30
|
+
if len(stderr.encode("utf-8", errors="ignore")) > max_output_bytes:
|
|
31
|
+
stderr = stderr.encode("utf-8", errors="ignore")[:max_output_bytes].decode("utf-8", errors="ignore")
|
|
32
|
+
truncated = True
|
|
33
|
+
|
|
34
|
+
if p.returncode == 0:
|
|
35
|
+
return WorkerResult("returned", stdout, stderr, 0, truncated)
|
|
36
|
+
if _QUOTA.search(stderr or stdout or ""):
|
|
37
|
+
return WorkerResult("paused", stdout, stderr, p.returncode, truncated)
|
|
38
|
+
return WorkerResult("failed", stdout, stderr, p.returncode, truncated)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# agents_inc/adapters/claude.py
|
|
2
|
+
"""Claude Code stdin worker: logged-in -p, tool-free. --bare is excluded: it disables OAuth (probe 2026-09-05)."""
|
|
3
|
+
DISALLOWED = ["Bash","PowerShell","Read","Edit","Write","Glob","Grep","Task",
|
|
4
|
+
"AskUserQuestion","TodoWrite","WebFetch","WebSearch","NotebookEdit"]
|
|
5
|
+
|
|
6
|
+
def build_cmd(model: str) -> list[str]:
|
|
7
|
+
return ["claude", "-p", "--model", model, "--setting-sources", "", "--strict-mcp-config",
|
|
8
|
+
"--disallowedTools", *DISALLOWED, "--"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# agents_inc/adapters/codex.py
|
|
2
|
+
"""Codex stdin worker: exec, read-only sandbox, empty cwd, no inherited env, no web. Prompt on stdin."""
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
def empty_cwd() -> str:
|
|
7
|
+
return tempfile.mkdtemp(prefix="wb-worker-")
|
|
8
|
+
|
|
9
|
+
def build_cmd(model: str, executable: str | None, cwd: str | None = None, effort: str = "medium") -> list[str]:
|
|
10
|
+
if not executable or not Path(executable).is_absolute():
|
|
11
|
+
raise ValueError("WB_CLI_NOT_FOUND: absolute Codex executable required")
|
|
12
|
+
cwd = cwd or empty_cwd()
|
|
13
|
+
return [executable, "exec", "-m", model, "-s", "read-only", "--skip-git-repo-check", "-C", cwd,
|
|
14
|
+
"-c", f"model_reasoning_effort={effort}",
|
|
15
|
+
"-c", 'shell_environment_policy.inherit="none"', "-c", 'web_search="disabled"', "-c", "features.shell_tool=false", "-"]
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""Local ollama adapter. HTTP behind subprocess, stdlib only, percent-based memory guards."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import sys, os, json, time, fcntl, threading, subprocess, math, re, http.client
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
# Read config from routing.json
|
|
8
|
+
def _load_config() -> dict:
|
|
9
|
+
try:
|
|
10
|
+
routing = json.loads((Path(__file__).parent.parent / "routing.json").read_text())
|
|
11
|
+
return routing.get("local", {})
|
|
12
|
+
except Exception:
|
|
13
|
+
return {}
|
|
14
|
+
|
|
15
|
+
def build_cmd(model: str) -> list[str]:
|
|
16
|
+
"""Return [sys.executable, "-m", "agents_inc.adapters.ollama", model]."""
|
|
17
|
+
return [sys.executable, "-m", "agents_inc.adapters.ollama", model]
|
|
18
|
+
|
|
19
|
+
_CONFIG = _load_config()
|
|
20
|
+
_LOCK_HANDLE = None # Module-level lock file handle, kept open for process lifetime
|
|
21
|
+
|
|
22
|
+
def _state_dir() -> Path:
|
|
23
|
+
env_path = os.environ.get("AGENTS_INC_LOCAL_STATE")
|
|
24
|
+
if env_path:
|
|
25
|
+
return Path(env_path)
|
|
26
|
+
return Path.home() / ".cache" / "agents-inc" / "local"
|
|
27
|
+
|
|
28
|
+
def _load_allowlist() -> list[str]:
|
|
29
|
+
return _CONFIG.get("models", [])
|
|
30
|
+
|
|
31
|
+
def mem_snapshot() -> dict | None:
|
|
32
|
+
"""Return {total_bytes, free_pct, free_bytes, swap_used_bytes} or None on error."""
|
|
33
|
+
uname = os.uname().sysname
|
|
34
|
+
try:
|
|
35
|
+
if uname == "Darwin":
|
|
36
|
+
# macOS: sysctl calls
|
|
37
|
+
import subprocess
|
|
38
|
+
total_b = int(subprocess.check_output(["sysctl", "-n", "hw.memsize"]).decode().strip())
|
|
39
|
+
free_pct = int(subprocess.check_output(["sysctl", "-n", "kern.memorystatus_level"]).decode().strip())
|
|
40
|
+
swap_output = subprocess.check_output(["sysctl", "-n", "vm.swapusage"]).decode().strip()
|
|
41
|
+
# Parse: "total = 5120.00M used = 3394.75M free = 1725.25M (encrypted)"
|
|
42
|
+
match = re.search(r"used\s*=\s*([\d.]+)\s*M", swap_output)
|
|
43
|
+
swap_used_b = int(float(match.group(1)) * 1024 * 1024) if match else 0
|
|
44
|
+
free_b = int(total_b * free_pct / 100)
|
|
45
|
+
return {"total_bytes": total_b, "free_pct": free_pct, "free_bytes": free_b, "swap_used_bytes": swap_used_b}
|
|
46
|
+
elif uname == "Linux":
|
|
47
|
+
# Linux: /proc/meminfo (kB)
|
|
48
|
+
meminfo = Path("/proc/meminfo").read_text()
|
|
49
|
+
values = {}
|
|
50
|
+
for line in meminfo.split("\n"):
|
|
51
|
+
if not line.strip():
|
|
52
|
+
continue
|
|
53
|
+
parts = line.split(":")
|
|
54
|
+
if len(parts) == 2:
|
|
55
|
+
key = parts[0].strip()
|
|
56
|
+
val_str = parts[1].strip().split()[0]
|
|
57
|
+
try:
|
|
58
|
+
values[key] = int(val_str) * 1024 # Convert kB to bytes
|
|
59
|
+
except ValueError:
|
|
60
|
+
pass
|
|
61
|
+
total_b = values.get("MemTotal", 0)
|
|
62
|
+
available_b = values.get("MemAvailable", 0)
|
|
63
|
+
swap_used_b = values.get("SwapTotal", 0) - values.get("SwapFree", 0)
|
|
64
|
+
if total_b <= 0:
|
|
65
|
+
return None
|
|
66
|
+
free_pct = int(100 * available_b / total_b) if total_b > 0 else 0
|
|
67
|
+
return {"total_bytes": total_b, "free_pct": free_pct, "free_bytes": available_b, "swap_used_bytes": swap_used_b}
|
|
68
|
+
else:
|
|
69
|
+
return None
|
|
70
|
+
except Exception:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def ready(model: str, base_url: str | None = None, opener=None) -> tuple[bool, str]:
|
|
74
|
+
"""GET /api/tags timeout connect_timeout_s; model in allowlist AND listed."""
|
|
75
|
+
if model not in _load_allowlist():
|
|
76
|
+
return False, "WB_LOCAL_MODEL_NOT_ALLOWED"
|
|
77
|
+
endpoint = base_url or _CONFIG.get("endpoint", "http://127.0.0.1:11434")
|
|
78
|
+
timeout_s = _CONFIG.get("connect_timeout_s", 2)
|
|
79
|
+
try:
|
|
80
|
+
host, port = endpoint.replace("http://", "").replace("https://", "").split(":")
|
|
81
|
+
port = int(port)
|
|
82
|
+
except Exception:
|
|
83
|
+
return False, "WB_LOCAL_ENDPOINT_INVALID"
|
|
84
|
+
try:
|
|
85
|
+
conn = http.client.HTTPConnection(host, port, timeout=timeout_s)
|
|
86
|
+
conn.request("GET", "/api/tags")
|
|
87
|
+
resp = conn.getresponse()
|
|
88
|
+
body = resp.read().decode()
|
|
89
|
+
conn.close()
|
|
90
|
+
if resp.status != 200:
|
|
91
|
+
return False, "WB_LOCAL_HTTP"
|
|
92
|
+
data = json.loads(body)
|
|
93
|
+
models = [m.get("name") for m in data.get("models", [])]
|
|
94
|
+
return model in models, "" if model in models else "WB_LOCAL_MODEL_NOT_READY"
|
|
95
|
+
except Exception as e:
|
|
96
|
+
return False, f"WB_LOCAL_HTTP: {str(e)[:100]}"
|
|
97
|
+
|
|
98
|
+
def _http_req(base_url: str, path: str, method: str = "GET", body: dict | None = None, timeout_s: float = 2,
|
|
99
|
+
conn_holder: dict | None = None) -> tuple[int, str]:
|
|
100
|
+
"""GET or POST to loopback only. Return (status, body_text). Raise ValueError on non-loopback/redirect.
|
|
101
|
+
conn_holder, if given, gets its "conn" key set to the live HTTPConnection right after connect, so a
|
|
102
|
+
watchdog thread can close it out from under an in-flight request (defect 4: abort must close the
|
|
103
|
+
generate conn before unload, or ollama won't unload mid-request)."""
|
|
104
|
+
url = base_url + path
|
|
105
|
+
# Enforce loopback: only 127.0.0.1, localhost, ::1; http scheme
|
|
106
|
+
if url.startswith("http://127.0.0.1:") or url.startswith("http://localhost:"):
|
|
107
|
+
host_port = url.replace("http://", "")
|
|
108
|
+
elif url.startswith("http://[::1]:"):
|
|
109
|
+
host_port = url.replace("http://", "").split("/", 1)[0]
|
|
110
|
+
else:
|
|
111
|
+
raise ValueError("WB_LOCAL_NON_LOOPBACK")
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
host, rest = host_port.split(":", 1)
|
|
115
|
+
port_and_path = rest.split("/", 1)
|
|
116
|
+
port = int(port_and_path[0])
|
|
117
|
+
path_part = "/" + port_and_path[1] if len(port_and_path) > 1 else "/"
|
|
118
|
+
except Exception:
|
|
119
|
+
raise ValueError("WB_LOCAL_HTTP")
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
conn = http.client.HTTPConnection(host, port, timeout=timeout_s)
|
|
123
|
+
if conn_holder is not None:
|
|
124
|
+
conn_holder["conn"] = conn
|
|
125
|
+
if method == "GET":
|
|
126
|
+
conn.request("GET", path_part)
|
|
127
|
+
else:
|
|
128
|
+
body_text = json.dumps(body or {})
|
|
129
|
+
conn.request("POST", path_part, body_text, {"Content-Type": "application/json"})
|
|
130
|
+
resp = conn.getresponse()
|
|
131
|
+
# Reject redirects
|
|
132
|
+
if resp.status >= 300 and resp.status < 400:
|
|
133
|
+
raise ValueError("WB_LOCAL_HTTP: redirect")
|
|
134
|
+
result = resp.read().decode()
|
|
135
|
+
conn.close()
|
|
136
|
+
return resp.status, result
|
|
137
|
+
except http.client.HTTPException as e:
|
|
138
|
+
raise ValueError(f"WB_LOCAL_HTTP: {str(e)[:100]}")
|
|
139
|
+
|
|
140
|
+
def admit(model: str, snap: dict | None, model_size: int | None = None) -> tuple[bool, str]:
|
|
141
|
+
"""snap None -> reject WB_LOCAL_NO_TELEMETRY; free_pct < min_free_pct -> reject WB_LOCAL_LOW_MEMORY; free_bytes < size*model_headroom -> WB_LOCAL_MODEL_TOO_BIG."""
|
|
142
|
+
if snap is None:
|
|
143
|
+
return False, "WB_LOCAL_NO_TELEMETRY"
|
|
144
|
+
min_free_pct = _CONFIG.get("min_free_pct", 35)
|
|
145
|
+
if snap.get("free_pct", 0) < min_free_pct:
|
|
146
|
+
return False, "WB_LOCAL_LOW_MEMORY"
|
|
147
|
+
# Check model headroom: need free_bytes >= model_size * model_headroom
|
|
148
|
+
if model_size is not None:
|
|
149
|
+
headroom = _CONFIG.get("model_headroom", 1.25)
|
|
150
|
+
needed = int(model_size * headroom)
|
|
151
|
+
if snap.get("free_bytes", 0) < needed:
|
|
152
|
+
return False, "WB_LOCAL_MODEL_TOO_BIG"
|
|
153
|
+
# Projected admission: reject upfront any model whose footprint, once loaded,
|
|
154
|
+
# would leave free_pct within projection_margin_pct of the watchdog's own abort
|
|
155
|
+
# line -- catches models that pass the raw headroom check today but would trip
|
|
156
|
+
# the watchdog abort moments after load (percent-scaled, no absolute GiB).
|
|
157
|
+
total_bytes = snap.get("total_bytes", 0)
|
|
158
|
+
if total_bytes > 0:
|
|
159
|
+
abort_free_pct = _CONFIG.get("abort_free_pct", 25)
|
|
160
|
+
projection_margin_pct = _CONFIG.get("projection_margin_pct", 5)
|
|
161
|
+
projected_free_pct = snap.get("free_pct", 0) - (model_size * headroom / total_bytes * 100)
|
|
162
|
+
if projected_free_pct < abort_free_pct + projection_margin_pct:
|
|
163
|
+
return False, "WB_LOCAL_PROJECTED_LOW_MEMORY"
|
|
164
|
+
return True, ""
|
|
165
|
+
|
|
166
|
+
def _estimate_tokens(prompt: str) -> int:
|
|
167
|
+
"""Estimate tokens = ceil(len(prompt.encode())/3)."""
|
|
168
|
+
return math.ceil(len(prompt.encode()) / 3)
|
|
169
|
+
|
|
170
|
+
def _acquire_lock(lock_path: Path) -> bool:
|
|
171
|
+
"""Acquire exclusive lock LOCK_EX|LOCK_NB. Return file handle if locked, None if busy."""
|
|
172
|
+
global _LOCK_HANDLE
|
|
173
|
+
try:
|
|
174
|
+
f = open(lock_path, "w")
|
|
175
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
176
|
+
_LOCK_HANDLE = f
|
|
177
|
+
return f
|
|
178
|
+
except (BlockingIOError, OSError):
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def _get_breaker_reason(state_dir: Path) -> str | None:
|
|
182
|
+
"""Return reason if breaker file present, else None."""
|
|
183
|
+
breaker_path = state_dir / "ollama.disabled"
|
|
184
|
+
if breaker_path.exists():
|
|
185
|
+
try:
|
|
186
|
+
data = json.loads(breaker_path.read_text())
|
|
187
|
+
return data.get("reason")
|
|
188
|
+
except Exception:
|
|
189
|
+
return "WB_LOCAL_DISABLED"
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
def _write_breaker(state_dir: Path, reason: str) -> None:
|
|
193
|
+
"""Write breaker file with reason and timestamp."""
|
|
194
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
breaker_path = state_dir / "ollama.disabled"
|
|
196
|
+
data = {"reason": reason, "ts": time.time()}
|
|
197
|
+
breaker_path.write_text(json.dumps(data))
|
|
198
|
+
|
|
199
|
+
def _reset_breaker(state_dir: Path) -> None:
|
|
200
|
+
"""Clear breaker file."""
|
|
201
|
+
breaker_path = state_dir / "ollama.disabled"
|
|
202
|
+
if breaker_path.exists():
|
|
203
|
+
breaker_path.unlink()
|
|
204
|
+
|
|
205
|
+
def _unload_and_verify(endpoint: str, model: str, state_dir: Path) -> bool:
|
|
206
|
+
"""POST unload, poll /api/ps until model absent <= unload_verify_s. Return True if verified, False if timeout."""
|
|
207
|
+
unload_verify_s = _CONFIG.get("unload_verify_s", 5)
|
|
208
|
+
try:
|
|
209
|
+
unload_body = {"model": model, "keep_alive": 0}
|
|
210
|
+
_http_req(endpoint, "/api/generate", method="POST", body=unload_body, timeout_s=10)
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
# Verify unload
|
|
215
|
+
start_unload = time.time()
|
|
216
|
+
while time.time() - start_unload < unload_verify_s:
|
|
217
|
+
try:
|
|
218
|
+
status, body = _http_req(endpoint, "/api/ps", method="GET", timeout_s=2)
|
|
219
|
+
if status == 200:
|
|
220
|
+
data = json.loads(body)
|
|
221
|
+
loaded = [m.get("name") for m in data.get("models", [])]
|
|
222
|
+
if model not in loaded:
|
|
223
|
+
return True # Unload verified
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
time.sleep(0.5)
|
|
227
|
+
|
|
228
|
+
# Unload not confirmed
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
def _log_usage(model: str, input_tokens: int, output_tokens: int) -> None:
|
|
232
|
+
from datetime import datetime, timezone
|
|
233
|
+
path = Path(os.path.expanduser("~")) / ".codex-bridge" / "usage.jsonl"
|
|
234
|
+
entry = {"ts": datetime.now(timezone.utc).isoformat(), "backend": "ollama", "model": model,
|
|
235
|
+
"input_tokens": input_tokens, "output_tokens": output_tokens}
|
|
236
|
+
try:
|
|
237
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
with path.open("a", encoding="utf-8") as f:
|
|
239
|
+
f.write(json.dumps(entry) + "\n")
|
|
240
|
+
except OSError:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main():
|
|
245
|
+
"""Read prompt from stdin; model arg. Checks: breaker, lock, budget, telemetry+admit. POST /api/generate with watchdog."""
|
|
246
|
+
global _LOCK_HANDLE
|
|
247
|
+
|
|
248
|
+
# Handle --reset flag
|
|
249
|
+
if len(sys.argv) > 1 and sys.argv[1] == "--reset":
|
|
250
|
+
state_dir = _state_dir()
|
|
251
|
+
_reset_breaker(state_dir)
|
|
252
|
+
print("ollama breaker cleared", file=sys.stderr)
|
|
253
|
+
sys.exit(0)
|
|
254
|
+
|
|
255
|
+
if len(sys.argv) < 2:
|
|
256
|
+
print("usage: ollama <model>", file=sys.stderr)
|
|
257
|
+
sys.exit(2)
|
|
258
|
+
|
|
259
|
+
model = sys.argv[1]
|
|
260
|
+
allowlist = _load_allowlist()
|
|
261
|
+
if model not in allowlist:
|
|
262
|
+
print("WB_LOCAL_MODEL_NOT_ALLOWED", file=sys.stderr)
|
|
263
|
+
sys.exit(2)
|
|
264
|
+
|
|
265
|
+
state_dir = _state_dir()
|
|
266
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
|
|
268
|
+
# Check breaker
|
|
269
|
+
breaker_reason = _get_breaker_reason(state_dir)
|
|
270
|
+
if breaker_reason:
|
|
271
|
+
print(f"WB_LOCAL_DISABLED: {breaker_reason}", file=sys.stderr)
|
|
272
|
+
sys.exit(2)
|
|
273
|
+
|
|
274
|
+
# Acquire lock
|
|
275
|
+
lock_path = state_dir / "ollama.lock"
|
|
276
|
+
lock_handle = _acquire_lock(lock_path)
|
|
277
|
+
if lock_handle is None:
|
|
278
|
+
print("WB_LOCAL_BUSY", file=sys.stderr)
|
|
279
|
+
sys.exit(2)
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
# Read prompt from stdin
|
|
283
|
+
prompt = sys.stdin.read()
|
|
284
|
+
|
|
285
|
+
# Check input budget
|
|
286
|
+
max_input_tokens = _CONFIG.get("max_input_tokens", 3072)
|
|
287
|
+
estimated_tokens = _estimate_tokens(prompt)
|
|
288
|
+
if estimated_tokens > max_input_tokens:
|
|
289
|
+
print("WB_LOCAL_INPUT_TOO_LARGE", file=sys.stderr)
|
|
290
|
+
sys.exit(2)
|
|
291
|
+
|
|
292
|
+
# Check telemetry
|
|
293
|
+
snap = mem_snapshot()
|
|
294
|
+
if snap is None:
|
|
295
|
+
print("WB_LOCAL_NO_TELEMETRY", file=sys.stderr)
|
|
296
|
+
sys.exit(2)
|
|
297
|
+
|
|
298
|
+
# Get model size from /api/tags for admit check. Distinguish "couldn't reach tags"
|
|
299
|
+
# (exit 1, no breaker -- server down) from "reachable but model not listed" (exit 2).
|
|
300
|
+
endpoint = _CONFIG.get("endpoint", "http://127.0.0.1:11434")
|
|
301
|
+
model_size = None
|
|
302
|
+
tags_status = None
|
|
303
|
+
try:
|
|
304
|
+
tags_status, tags_body = _http_req(endpoint, "/api/tags", method="GET", timeout_s=_CONFIG.get("connect_timeout_s", 2))
|
|
305
|
+
except Exception:
|
|
306
|
+
tags_status = None
|
|
307
|
+
|
|
308
|
+
if tags_status != 200:
|
|
309
|
+
print("WB_LOCAL_HTTP: tags unavailable", file=sys.stderr)
|
|
310
|
+
sys.exit(1)
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
data = json.loads(tags_body)
|
|
314
|
+
for m in data.get("models", []):
|
|
315
|
+
if m.get("name") == model:
|
|
316
|
+
model_size = m.get("size")
|
|
317
|
+
break
|
|
318
|
+
except Exception:
|
|
319
|
+
model_size = None
|
|
320
|
+
|
|
321
|
+
if model_size is None:
|
|
322
|
+
print("WB_LOCAL_MODEL_NOT_ALLOWED", file=sys.stderr)
|
|
323
|
+
sys.exit(2)
|
|
324
|
+
|
|
325
|
+
# Check admit
|
|
326
|
+
admitted, reason = admit(model, snap, model_size=model_size)
|
|
327
|
+
if not admitted:
|
|
328
|
+
print(reason, file=sys.stderr)
|
|
329
|
+
sys.exit(2)
|
|
330
|
+
|
|
331
|
+
# POST /api/generate in worker thread, watchdog in main
|
|
332
|
+
deadline_s = _CONFIG.get("deadline_s", 90)
|
|
333
|
+
num_ctx = _CONFIG.get("num_ctx", 4096)
|
|
334
|
+
num_predict = _CONFIG.get("num_predict", 512)
|
|
335
|
+
no_think_models = _CONFIG.get("no_think_models", [])
|
|
336
|
+
watchdog_interval_s = _CONFIG.get("watchdog_interval_s", 2)
|
|
337
|
+
abort_free_pct = _CONFIG.get("abort_free_pct", 25)
|
|
338
|
+
max_swap_growth_pct = _CONFIG.get("max_swap_growth_pct", 2)
|
|
339
|
+
worker_timeout_s = _CONFIG.get("worker_timeout_s", 100)
|
|
340
|
+
|
|
341
|
+
req_body = {
|
|
342
|
+
"model": model,
|
|
343
|
+
"prompt": prompt,
|
|
344
|
+
"stream": False,
|
|
345
|
+
"keep_alive": 0,
|
|
346
|
+
"options": {
|
|
347
|
+
"num_ctx": num_ctx,
|
|
348
|
+
"num_predict": num_predict
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if model in no_think_models:
|
|
352
|
+
req_body["think"] = False
|
|
353
|
+
|
|
354
|
+
# result also doubles as the conn_holder passed to _http_req, so the watchdog can
|
|
355
|
+
# close result["conn"] from the main thread to abort an in-flight generate (defect 4).
|
|
356
|
+
result = {"output": None, "error": None, "http_status": None, "connect_fail": False, "conn": None}
|
|
357
|
+
|
|
358
|
+
def worker_fn():
|
|
359
|
+
try:
|
|
360
|
+
status, body = _http_req(endpoint, "/api/generate", method="POST", body=req_body,
|
|
361
|
+
timeout_s=worker_timeout_s, conn_holder=result)
|
|
362
|
+
if status != 200:
|
|
363
|
+
result["http_status"] = status
|
|
364
|
+
result["error"] = f"WB_LOCAL_HTTP: HTTP {status}"
|
|
365
|
+
result["connect_fail"] = False
|
|
366
|
+
return
|
|
367
|
+
data = json.loads(body)
|
|
368
|
+
result["output"] = data.get("response", "")
|
|
369
|
+
result["usage"] = (data.get("prompt_eval_count", 0), data.get("eval_count", 0))
|
|
370
|
+
except ValueError as e:
|
|
371
|
+
# raised by _http_req for non-loopback/redirect/malformed target -- not a
|
|
372
|
+
# connect-fail, server was reachable enough to answer wrong.
|
|
373
|
+
result["error"] = str(e)
|
|
374
|
+
result["connect_fail"] = False
|
|
375
|
+
except OSError as e:
|
|
376
|
+
# connect refused / timed out / socket-level failure = server down.
|
|
377
|
+
# Spec: no breaker for this case.
|
|
378
|
+
result["error"] = f"WB_LOCAL_HTTP: {str(e)[:100]}"
|
|
379
|
+
result["connect_fail"] = True
|
|
380
|
+
except Exception as e:
|
|
381
|
+
result["error"] = f"WB_LOCAL_HTTP: {str(e)[:100]}"
|
|
382
|
+
result["connect_fail"] = False
|
|
383
|
+
|
|
384
|
+
worker = threading.Thread(target=worker_fn, daemon=True)
|
|
385
|
+
worker.start()
|
|
386
|
+
|
|
387
|
+
start_time = time.time()
|
|
388
|
+
snap_baseline = snap.copy()
|
|
389
|
+
watchdog_abort_reason = None
|
|
390
|
+
|
|
391
|
+
while worker.is_alive():
|
|
392
|
+
elapsed = time.time() - start_time
|
|
393
|
+
|
|
394
|
+
# Check deadline
|
|
395
|
+
if elapsed > deadline_s:
|
|
396
|
+
watchdog_abort_reason = "WB_LOCAL_DEADLINE"
|
|
397
|
+
break
|
|
398
|
+
|
|
399
|
+
# Telemetry lost mid-run -> abort. Do NOT silently continue (defect 1).
|
|
400
|
+
snap_current = mem_snapshot()
|
|
401
|
+
if snap_current is None:
|
|
402
|
+
watchdog_abort_reason = "WB_LOCAL_TELEMETRY_LOST"
|
|
403
|
+
break
|
|
404
|
+
|
|
405
|
+
# Check free memory
|
|
406
|
+
if snap_current.get("free_pct", 0) < abort_free_pct:
|
|
407
|
+
watchdog_abort_reason = "WB_LOCAL_LOW_MEMORY_ABORT"
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
# Check swap growth
|
|
411
|
+
swap_growth_b = snap_current.get("swap_used_bytes", 0) - snap_baseline.get("swap_used_bytes", 0)
|
|
412
|
+
max_growth_b = snap_baseline.get("total_bytes", 1) * max_swap_growth_pct / 100
|
|
413
|
+
if swap_growth_b > max_growth_b:
|
|
414
|
+
watchdog_abort_reason = "WB_LOCAL_SWAP_GROWTH"
|
|
415
|
+
break
|
|
416
|
+
|
|
417
|
+
time.sleep(watchdog_interval_s)
|
|
418
|
+
|
|
419
|
+
# On abort, close the in-flight generate conn first -- ollama won't unload a model
|
|
420
|
+
# mid-request, so the worker's open HTTPConnection must die before we try (defect 4).
|
|
421
|
+
if watchdog_abort_reason and result.get("conn") is not None:
|
|
422
|
+
try:
|
|
423
|
+
result["conn"].close()
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
# Unload + verify (always run after any generate path)
|
|
428
|
+
unload_ok = _unload_and_verify(endpoint, model, state_dir)
|
|
429
|
+
|
|
430
|
+
# Handle abort (watchdog triggered)
|
|
431
|
+
if watchdog_abort_reason:
|
|
432
|
+
if not unload_ok:
|
|
433
|
+
# If unload failed, note both reasons
|
|
434
|
+
_write_breaker(state_dir, f"{watchdog_abort_reason}; unload failed")
|
|
435
|
+
else:
|
|
436
|
+
_write_breaker(state_dir, watchdog_abort_reason)
|
|
437
|
+
print(watchdog_abort_reason, file=sys.stderr)
|
|
438
|
+
sys.exit(3)
|
|
439
|
+
|
|
440
|
+
# Handle unload failure (if no watchdog abort)
|
|
441
|
+
if not unload_ok:
|
|
442
|
+
_write_breaker(state_dir, "WB_LOCAL_UNLOAD_UNCONFIRMED")
|
|
443
|
+
print("WB_LOCAL_UNLOAD_UNCONFIRMED", file=sys.stderr)
|
|
444
|
+
sys.exit(4)
|
|
445
|
+
|
|
446
|
+
# Worker done, check result. connect_fail (server down) -> no breaker; any other
|
|
447
|
+
# HTTP failure (non-200, malformed body, redirect) -> breaker.
|
|
448
|
+
if result["error"]:
|
|
449
|
+
reason = result["error"]
|
|
450
|
+
if not result["connect_fail"]:
|
|
451
|
+
_write_breaker(state_dir, reason)
|
|
452
|
+
print(reason, file=sys.stderr)
|
|
453
|
+
sys.exit(1)
|
|
454
|
+
|
|
455
|
+
# Success: log usage (best effort), print response, exit 0
|
|
456
|
+
if result.get("usage"):
|
|
457
|
+
_log_usage(model, *result["usage"])
|
|
458
|
+
if result["output"] is not None:
|
|
459
|
+
print(result["output"], end="")
|
|
460
|
+
sys.exit(0)
|
|
461
|
+
|
|
462
|
+
finally:
|
|
463
|
+
# Close lock handle (do NOT unlink - keeps exclusivity for waiters)
|
|
464
|
+
if _LOCK_HANDLE:
|
|
465
|
+
try:
|
|
466
|
+
_LOCK_HANDLE.close()
|
|
467
|
+
except Exception:
|
|
468
|
+
pass
|
|
469
|
+
_LOCK_HANDLE = None
|
|
470
|
+
|
|
471
|
+
if __name__ == "__main__":
|
|
472
|
+
main()
|