puremethod 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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- writer/types.py +61 -0
ledger/storage.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Entry, rejection and artifact-index storage: SQLite or PostgreSQL (04-ledger.md §4).
|
|
2
|
+
|
|
3
|
+
Both refuse UPDATE and DELETE with triggers, as a second barrier behind the API (§5 step 4).
|
|
4
|
+
Payloads and refs are stored as canonical JSON text, so the stored bytes are the hashed bytes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
from .canonical import canonical_json
|
|
17
|
+
from .errors import LedgerError
|
|
18
|
+
from .model import Entry, Rejection
|
|
19
|
+
|
|
20
|
+
EntryBuilder = Callable[[str | None, int], Entry]
|
|
21
|
+
|
|
22
|
+
_TABLES = ("entries", "rejections", "artifacts")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Storage(Protocol):
|
|
26
|
+
def append(self, run_id: str, build: EntryBuilder) -> Entry: ...
|
|
27
|
+
def entries(self, run_id: str, since_seq: int = 0) -> list[Entry]: ...
|
|
28
|
+
def run_exists(self, run_id: str) -> bool: ...
|
|
29
|
+
def add_rejection(self, rejection: Rejection) -> None: ...
|
|
30
|
+
def rejections(self, run_id: str) -> list[Rejection]: ...
|
|
31
|
+
def add_artifact(self, run_id: str, digest: str, kind: str, size: int, at: str) -> None: ...
|
|
32
|
+
def artifact_kind(self, run_id: str, digest: str) -> str | None: ...
|
|
33
|
+
def close(self) -> None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _row_to_entry(row: tuple[Any, ...]) -> Entry:
|
|
37
|
+
run_id, seq, entry_id, type_, at, actor, payload, refs, prev_hash, hash_ = row
|
|
38
|
+
return Entry(run_id=run_id, seq=int(seq), entry_id=entry_id, type=type_, at=at,
|
|
39
|
+
actor=actor, payload=json.loads(payload), refs=json.loads(refs),
|
|
40
|
+
prev_hash=prev_hash, hash=hash_)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _entry_params(e: Entry) -> tuple[Any, ...]:
|
|
44
|
+
return (e.run_id, e.seq, e.entry_id, e.type, e.at, e.actor, canonical_json(e.payload),
|
|
45
|
+
canonical_json(e.refs), e.prev_hash, e.hash)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_COLUMNS = "run_id, seq, entry_id, type, at, actor, payload, refs, prev_hash, hash"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SQLiteStorage:
|
|
52
|
+
def __init__(self, path: str) -> None:
|
|
53
|
+
if path != ":memory:":
|
|
54
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
self._conn = sqlite3.connect(path, isolation_level=None, check_same_thread=False)
|
|
56
|
+
self._conn.execute("PRAGMA foreign_keys = ON")
|
|
57
|
+
if path != ":memory:":
|
|
58
|
+
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
59
|
+
self._init_schema()
|
|
60
|
+
|
|
61
|
+
def _init_schema(self) -> None:
|
|
62
|
+
c = self._conn
|
|
63
|
+
c.execute("""CREATE TABLE IF NOT EXISTS entries (
|
|
64
|
+
run_id TEXT NOT NULL, seq INTEGER NOT NULL, entry_id TEXT NOT NULL UNIQUE,
|
|
65
|
+
type TEXT NOT NULL, at TEXT NOT NULL, actor TEXT NOT NULL, payload TEXT NOT NULL,
|
|
66
|
+
refs TEXT NOT NULL, prev_hash TEXT NOT NULL, hash TEXT NOT NULL,
|
|
67
|
+
PRIMARY KEY (run_id, seq))""")
|
|
68
|
+
c.execute("""CREATE TABLE IF NOT EXISTS rejections (
|
|
69
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, at TEXT NOT NULL,
|
|
70
|
+
target TEXT NOT NULL, rule TEXT NOT NULL, message TEXT NOT NULL)""")
|
|
71
|
+
c.execute("""CREATE TABLE IF NOT EXISTS artifacts (
|
|
72
|
+
run_id TEXT NOT NULL, digest TEXT NOT NULL, kind TEXT NOT NULL,
|
|
73
|
+
size INTEGER NOT NULL, at TEXT NOT NULL, PRIMARY KEY (run_id, digest))""")
|
|
74
|
+
for table in _TABLES:
|
|
75
|
+
for op in ("UPDATE", "DELETE"):
|
|
76
|
+
c.execute(
|
|
77
|
+
f"CREATE TRIGGER IF NOT EXISTS {table}_no_{op.lower()} BEFORE {op} ON "
|
|
78
|
+
f"{table} BEGIN SELECT RAISE(ABORT, 'ledger: {table} is append-only'); END"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@contextmanager
|
|
82
|
+
def _tx(self) -> Iterator[sqlite3.Connection]:
|
|
83
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
84
|
+
try:
|
|
85
|
+
yield self._conn
|
|
86
|
+
except BaseException:
|
|
87
|
+
self._conn.execute("ROLLBACK")
|
|
88
|
+
raise
|
|
89
|
+
else:
|
|
90
|
+
self._conn.execute("COMMIT")
|
|
91
|
+
|
|
92
|
+
def append(self, run_id: str, build: EntryBuilder) -> Entry:
|
|
93
|
+
with self._tx() as c:
|
|
94
|
+
row = c.execute("SELECT seq, hash FROM entries WHERE run_id = ? "
|
|
95
|
+
"ORDER BY seq DESC LIMIT 1", (run_id,)).fetchone()
|
|
96
|
+
prev_hash, next_seq = (None, 1) if row is None else (row[1], int(row[0]) + 1)
|
|
97
|
+
entry = build(prev_hash, next_seq)
|
|
98
|
+
c.execute(f"INSERT INTO entries ({_COLUMNS}) VALUES (?,?,?,?,?,?,?,?,?,?)", # noqa: S608
|
|
99
|
+
_entry_params(entry))
|
|
100
|
+
return entry
|
|
101
|
+
|
|
102
|
+
def entries(self, run_id: str, since_seq: int = 0) -> list[Entry]:
|
|
103
|
+
rows = self._conn.execute(
|
|
104
|
+
f"SELECT {_COLUMNS} FROM entries WHERE run_id = ? AND seq > ? ORDER BY seq", # noqa: S608
|
|
105
|
+
(run_id, since_seq)).fetchall()
|
|
106
|
+
return [_row_to_entry(r) for r in rows]
|
|
107
|
+
|
|
108
|
+
def run_exists(self, run_id: str) -> bool:
|
|
109
|
+
row = self._conn.execute("SELECT 1 FROM entries WHERE run_id = ? AND seq = 1",
|
|
110
|
+
(run_id,)).fetchone()
|
|
111
|
+
return row is not None
|
|
112
|
+
|
|
113
|
+
def add_rejection(self, rejection: Rejection) -> None:
|
|
114
|
+
self._conn.execute(
|
|
115
|
+
"INSERT INTO rejections (run_id, at, target, rule, message) VALUES (?,?,?,?,?)",
|
|
116
|
+
(rejection.run_id, rejection.at, rejection.target, rejection.rule,
|
|
117
|
+
rejection.message))
|
|
118
|
+
|
|
119
|
+
def rejections(self, run_id: str) -> list[Rejection]:
|
|
120
|
+
rows = self._conn.execute("SELECT run_id, at, target, rule, message FROM rejections "
|
|
121
|
+
"WHERE run_id = ? ORDER BY id", (run_id,)).fetchall()
|
|
122
|
+
return [Rejection(*r) for r in rows]
|
|
123
|
+
|
|
124
|
+
def add_artifact(self, run_id: str, digest: str, kind: str, size: int, at: str) -> None:
|
|
125
|
+
self._conn.execute("INSERT OR IGNORE INTO artifacts (run_id, digest, kind, size, at) "
|
|
126
|
+
"VALUES (?,?,?,?,?)", (run_id, digest, kind, size, at))
|
|
127
|
+
|
|
128
|
+
def artifact_kind(self, run_id: str, digest: str) -> str | None:
|
|
129
|
+
row = self._conn.execute("SELECT kind FROM artifacts WHERE run_id = ? AND digest = ?",
|
|
130
|
+
(run_id, digest)).fetchone()
|
|
131
|
+
return None if row is None else str(row[0])
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
self._conn.close()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class PostgresStorage:
|
|
138
|
+
def __init__(self, url: str) -> None:
|
|
139
|
+
import psycopg
|
|
140
|
+
|
|
141
|
+
self._conn = psycopg.connect(url, autocommit=True)
|
|
142
|
+
self._init_schema()
|
|
143
|
+
|
|
144
|
+
def _init_schema(self) -> None:
|
|
145
|
+
with self._conn.transaction():
|
|
146
|
+
c = self._conn
|
|
147
|
+
c.execute("""CREATE TABLE IF NOT EXISTS entries (
|
|
148
|
+
run_id TEXT NOT NULL, seq BIGINT NOT NULL, entry_id TEXT NOT NULL UNIQUE,
|
|
149
|
+
type TEXT NOT NULL, at TEXT NOT NULL, actor TEXT NOT NULL, payload TEXT NOT NULL,
|
|
150
|
+
refs TEXT NOT NULL, prev_hash TEXT NOT NULL, hash TEXT NOT NULL,
|
|
151
|
+
PRIMARY KEY (run_id, seq))""")
|
|
152
|
+
c.execute("""CREATE TABLE IF NOT EXISTS rejections (
|
|
153
|
+
id BIGSERIAL PRIMARY KEY, run_id TEXT NOT NULL, at TEXT NOT NULL,
|
|
154
|
+
target TEXT NOT NULL, rule TEXT NOT NULL, message TEXT NOT NULL)""")
|
|
155
|
+
c.execute("""CREATE TABLE IF NOT EXISTS artifacts (
|
|
156
|
+
run_id TEXT NOT NULL, digest TEXT NOT NULL, kind TEXT NOT NULL,
|
|
157
|
+
size BIGINT NOT NULL, at TEXT NOT NULL, PRIMARY KEY (run_id, digest))""")
|
|
158
|
+
c.execute("""CREATE OR REPLACE FUNCTION ledger_append_only() RETURNS trigger
|
|
159
|
+
LANGUAGE plpgsql AS $$ BEGIN
|
|
160
|
+
RAISE EXCEPTION 'ledger: % is append-only', TG_TABLE_NAME; END $$""")
|
|
161
|
+
for table in _TABLES:
|
|
162
|
+
c.execute(f"DROP TRIGGER IF EXISTS {table}_append_only ON {table}")
|
|
163
|
+
c.execute(f"CREATE TRIGGER {table}_append_only BEFORE UPDATE OR DELETE ON "
|
|
164
|
+
f"{table} FOR EACH ROW EXECUTE FUNCTION ledger_append_only()")
|
|
165
|
+
c.execute(f"DROP TRIGGER IF EXISTS {table}_no_truncate ON {table}")
|
|
166
|
+
c.execute(f"CREATE TRIGGER {table}_no_truncate BEFORE TRUNCATE ON {table} "
|
|
167
|
+
f"FOR EACH STATEMENT EXECUTE FUNCTION ledger_append_only()")
|
|
168
|
+
|
|
169
|
+
def append(self, run_id: str, build: EntryBuilder) -> Entry:
|
|
170
|
+
with self._conn.transaction():
|
|
171
|
+
c = self._conn
|
|
172
|
+
c.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", (run_id,))
|
|
173
|
+
row = c.execute("SELECT seq, hash FROM entries WHERE run_id = %s "
|
|
174
|
+
"ORDER BY seq DESC LIMIT 1", (run_id,)).fetchone()
|
|
175
|
+
prev_hash, next_seq = (None, 1) if row is None else (row[1], int(row[0]) + 1)
|
|
176
|
+
entry = build(prev_hash, next_seq)
|
|
177
|
+
c.execute(f"INSERT INTO entries ({_COLUMNS}) VALUES " # noqa: S608
|
|
178
|
+
"(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", _entry_params(entry))
|
|
179
|
+
return entry
|
|
180
|
+
|
|
181
|
+
def entries(self, run_id: str, since_seq: int = 0) -> list[Entry]:
|
|
182
|
+
rows = self._conn.execute(
|
|
183
|
+
f"SELECT {_COLUMNS} FROM entries WHERE run_id = %s AND seq > %s ORDER BY seq", # noqa: S608
|
|
184
|
+
(run_id, since_seq)).fetchall()
|
|
185
|
+
return [_row_to_entry(tuple(r)) for r in rows]
|
|
186
|
+
|
|
187
|
+
def run_exists(self, run_id: str) -> bool:
|
|
188
|
+
row = self._conn.execute("SELECT 1 FROM entries WHERE run_id = %s AND seq = 1",
|
|
189
|
+
(run_id,)).fetchone()
|
|
190
|
+
return row is not None
|
|
191
|
+
|
|
192
|
+
def add_rejection(self, rejection: Rejection) -> None:
|
|
193
|
+
self._conn.execute(
|
|
194
|
+
"INSERT INTO rejections (run_id, at, target, rule, message) "
|
|
195
|
+
"VALUES (%s,%s,%s,%s,%s)",
|
|
196
|
+
(rejection.run_id, rejection.at, rejection.target, rejection.rule,
|
|
197
|
+
rejection.message))
|
|
198
|
+
|
|
199
|
+
def rejections(self, run_id: str) -> list[Rejection]:
|
|
200
|
+
rows = self._conn.execute("SELECT run_id, at, target, rule, message FROM rejections "
|
|
201
|
+
"WHERE run_id = %s ORDER BY id", (run_id,)).fetchall()
|
|
202
|
+
return [Rejection(*r) for r in rows]
|
|
203
|
+
|
|
204
|
+
def add_artifact(self, run_id: str, digest: str, kind: str, size: int, at: str) -> None:
|
|
205
|
+
self._conn.execute("INSERT INTO artifacts (run_id, digest, kind, size, at) "
|
|
206
|
+
"VALUES (%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING",
|
|
207
|
+
(run_id, digest, kind, size, at))
|
|
208
|
+
|
|
209
|
+
def artifact_kind(self, run_id: str, digest: str) -> str | None:
|
|
210
|
+
row = self._conn.execute("SELECT kind FROM artifacts WHERE run_id = %s AND digest = %s",
|
|
211
|
+
(run_id, digest)).fetchone()
|
|
212
|
+
return None if row is None else str(row[0])
|
|
213
|
+
|
|
214
|
+
def close(self) -> None:
|
|
215
|
+
self._conn.close()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def open_storage(url: str) -> Storage:
|
|
219
|
+
if url.startswith("sqlite:///"):
|
|
220
|
+
return SQLiteStorage(url.removeprefix("sqlite:///"))
|
|
221
|
+
if url == "sqlite://":
|
|
222
|
+
return SQLiteStorage(":memory:")
|
|
223
|
+
if url.startswith(("postgresql://", "postgres://")):
|
|
224
|
+
return PostgresStorage(url)
|
|
225
|
+
# Never echo the URL: a PostgreSQL URL can carry a password.
|
|
226
|
+
raise LedgerError("unsupported storage URL scheme (use sqlite:/// or postgresql://)")
|
llm/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""llm: model-agnostic, metered LLM calls (docs/knowledge_base/07-llm.md)."""
|
|
2
|
+
|
|
3
|
+
from .core import LLM, QUOTE_RULE, PreflightReport
|
|
4
|
+
from .prices import DEFAULT_PRICES, ModelPrice, PriceTable, bound_usd, cost_usd
|
|
5
|
+
from .tokens import estimate_tokens
|
|
6
|
+
from .types import (
|
|
7
|
+
ROLES,
|
|
8
|
+
AuthError,
|
|
9
|
+
CallRecord,
|
|
10
|
+
InvalidOutput,
|
|
11
|
+
LLMError,
|
|
12
|
+
LLMResult,
|
|
13
|
+
Message,
|
|
14
|
+
PriceUnknown,
|
|
15
|
+
ProviderUnavailable,
|
|
16
|
+
QuotaExhausted,
|
|
17
|
+
RateLimited,
|
|
18
|
+
RequestRejected,
|
|
19
|
+
Usage,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"DEFAULT_PRICES",
|
|
24
|
+
"LLM",
|
|
25
|
+
"QUOTE_RULE",
|
|
26
|
+
"ROLES",
|
|
27
|
+
"AuthError",
|
|
28
|
+
"CallRecord",
|
|
29
|
+
"InvalidOutput",
|
|
30
|
+
"LLMError",
|
|
31
|
+
"LLMResult",
|
|
32
|
+
"Message",
|
|
33
|
+
"ModelPrice",
|
|
34
|
+
"PreflightReport",
|
|
35
|
+
"PriceTable",
|
|
36
|
+
"PriceUnknown",
|
|
37
|
+
"ProviderUnavailable",
|
|
38
|
+
"QuotaExhausted",
|
|
39
|
+
"RateLimited",
|
|
40
|
+
"RequestRejected",
|
|
41
|
+
"Usage",
|
|
42
|
+
"bound_usd",
|
|
43
|
+
"cost_usd",
|
|
44
|
+
"estimate_tokens",
|
|
45
|
+
]
|
llm/backends/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Anthropic backend (official `anthropic` SDK, 2.x).
|
|
2
|
+
|
|
3
|
+
- Token counts come from `messages.count_tokens`, exact for the same request shape,
|
|
4
|
+
including the structured-output schema (07-llm.md §5 step 2).
|
|
5
|
+
- Sampling parameters are not sent: API version 2026-10-21 rejects `temperature` on every
|
|
6
|
+
model (§5 step 8, "where supported").
|
|
7
|
+
- The SDK's own retries are off, so each attempt gets its own CallRecord (INV-1).
|
|
8
|
+
- Server-side refusal fallbacks are not enabled: they would switch models without the run
|
|
9
|
+
config naming the fallback (INV-4).
|
|
10
|
+
- Structured output sends what the API can enforce: the SDK's `transform_schema` drops
|
|
11
|
+
unsupported constraints (`maxItems`, `minLength`, `minimum`, ...) into descriptions. A schema
|
|
12
|
+
it cannot express (an object used as a map) goes in the system prompt instead. Either way
|
|
13
|
+
`LLM.complete` validates the reply against the full schema and retries (07-llm.md §5 step 4,
|
|
14
|
+
INV-6), so no constraint is lost.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import anthropic
|
|
23
|
+
|
|
24
|
+
from ..types import Message, Usage
|
|
25
|
+
from .base import BackendError, Completion, scrub
|
|
26
|
+
|
|
27
|
+
_SCHEMA_INSTRUCTION = "Reply with only a JSON value that matches this JSON Schema:\n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _has_map(schema: Any) -> bool:
|
|
31
|
+
"""An object whose extra keys are allowed: structured outputs only take
|
|
32
|
+
`additionalProperties: false`, so its shape cannot be enforced."""
|
|
33
|
+
if isinstance(schema, dict):
|
|
34
|
+
extra = schema.get("additionalProperties", False)
|
|
35
|
+
if schema.get("type") == "object" and extra is not False and (
|
|
36
|
+
extra is not None or not schema.get("properties")):
|
|
37
|
+
return True
|
|
38
|
+
return any(_has_map(v) for v in schema.values())
|
|
39
|
+
if isinstance(schema, list):
|
|
40
|
+
return any(_has_map(v) for v in schema)
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _typed(schema: Any) -> Any:
|
|
45
|
+
"""A copy in which an `enum` of strings without a `type` says `"type": "string"`, which
|
|
46
|
+
`transform_schema` requires; the accepted values are unchanged."""
|
|
47
|
+
if isinstance(schema, list):
|
|
48
|
+
return [_typed(v) for v in schema]
|
|
49
|
+
if not isinstance(schema, dict):
|
|
50
|
+
return schema
|
|
51
|
+
out = {k: _typed(v) for k, v in schema.items()}
|
|
52
|
+
enum = out.get("enum")
|
|
53
|
+
if "type" not in out and isinstance(enum, list) and enum and all(
|
|
54
|
+
isinstance(v, str) for v in enum):
|
|
55
|
+
out["type"] = "string"
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _enforceable(schema: dict[str, Any]) -> dict[str, Any] | None:
|
|
60
|
+
"""The schema as structured outputs can take it, or None when it cannot."""
|
|
61
|
+
if _has_map(schema):
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
return anthropic.transform_schema(_typed(schema))
|
|
65
|
+
except (ValueError, TypeError, AssertionError):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AnthropicBackend:
|
|
70
|
+
provider = "anthropic"
|
|
71
|
+
|
|
72
|
+
def __init__(self, api_key: str, base_url: str | None = None,
|
|
73
|
+
timeout_s: float = 600.0) -> None:
|
|
74
|
+
self._secrets = [api_key]
|
|
75
|
+
self._client = anthropic.Anthropic(api_key=api_key, base_url=base_url, max_retries=0,
|
|
76
|
+
timeout=timeout_s)
|
|
77
|
+
|
|
78
|
+
def _classify(self, exc: Exception) -> BackendError:
|
|
79
|
+
msg = scrub(f"{type(exc).__name__}: {getattr(exc, 'message', '')}", self._secrets)
|
|
80
|
+
if isinstance(exc, anthropic.RateLimitError):
|
|
81
|
+
return BackendError("rate_limited", msg)
|
|
82
|
+
if isinstance(exc, anthropic.AuthenticationError | anthropic.PermissionDeniedError):
|
|
83
|
+
return BackendError("auth", msg)
|
|
84
|
+
if isinstance(exc, anthropic.APIStatusError):
|
|
85
|
+
if exc.status_code == 402:
|
|
86
|
+
return BackendError("quota", msg)
|
|
87
|
+
if exc.status_code >= 500:
|
|
88
|
+
return BackendError("transient", msg)
|
|
89
|
+
return BackendError("rejected", msg)
|
|
90
|
+
if isinstance(exc, anthropic.APIConnectionError): # includes timeouts
|
|
91
|
+
return BackendError("transient", msg)
|
|
92
|
+
return BackendError("transient", scrub(type(exc).__name__, self._secrets))
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _params(system: str, messages: list[Message],
|
|
96
|
+
schema: dict[str, Any] | None) -> dict[str, Any]:
|
|
97
|
+
params: dict[str, Any] = {
|
|
98
|
+
"messages": [{"role": m["role"], "content": m["content"]} for m in messages],
|
|
99
|
+
}
|
|
100
|
+
sent = _enforceable(schema) if schema is not None else None
|
|
101
|
+
if schema is not None and sent is None:
|
|
102
|
+
system = (f"{system}\n\n" if system else "") + _SCHEMA_INSTRUCTION + json.dumps(
|
|
103
|
+
schema, sort_keys=True)
|
|
104
|
+
if system:
|
|
105
|
+
params["system"] = system
|
|
106
|
+
if sent is not None:
|
|
107
|
+
params["output_config"] = {"format": {"type": "json_schema", "schema": sent}}
|
|
108
|
+
return params
|
|
109
|
+
|
|
110
|
+
def check_model(self, model: str) -> str | None:
|
|
111
|
+
try:
|
|
112
|
+
self._client.models.retrieve(model)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
return self._classify(exc).message
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def count_input_tokens(self, model: str, system: str, messages: list[Message],
|
|
118
|
+
schema: dict[str, Any] | None) -> int | None:
|
|
119
|
+
try:
|
|
120
|
+
resp = self._client.messages.count_tokens(model=model,
|
|
121
|
+
**self._params(system, messages, schema))
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
raise self._classify(exc) from None
|
|
124
|
+
return int(resp.input_tokens)
|
|
125
|
+
|
|
126
|
+
def complete(self, model: str, system: str, messages: list[Message], *, max_tokens: int,
|
|
127
|
+
temperature: float | None, seed: int | None,
|
|
128
|
+
schema: dict[str, Any] | None) -> Completion:
|
|
129
|
+
try:
|
|
130
|
+
# Streaming, so large max_tokens values do not hit request timeouts.
|
|
131
|
+
with self._client.messages.stream(model=model, max_tokens=max_tokens,
|
|
132
|
+
**self._params(system, messages,
|
|
133
|
+
schema)) as stream:
|
|
134
|
+
resp = stream.get_final_message()
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
raise self._classify(exc) from None
|
|
137
|
+
text = "".join(b.text for b in resp.content if b.type == "text")
|
|
138
|
+
u = resp.usage
|
|
139
|
+
usage = Usage(input_tokens=u.input_tokens, output_tokens=u.output_tokens,
|
|
140
|
+
cache_read_tokens=u.cache_read_input_tokens or 0,
|
|
141
|
+
cache_write_tokens=u.cache_creation_input_tokens or 0)
|
|
142
|
+
return Completion(text=text, usage=usage, model_reported=resp.model,
|
|
143
|
+
stop_reason=resp.stop_reason,
|
|
144
|
+
sent={"max_tokens": max_tokens, "temperature": None, "seed": None,
|
|
145
|
+
"structured_output": schema is not None})
|
llm/backends/base.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The provider backend interface. Each backend lives in its own file and SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Literal, Protocol
|
|
7
|
+
|
|
8
|
+
from ..types import Message, Usage
|
|
9
|
+
|
|
10
|
+
ErrorKind = Literal["transient", "rate_limited", "quota", "auth", "rejected"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BackendError(Exception):
|
|
14
|
+
"""A classified provider failure. `message` is already scrubbed of credentials."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, kind: ErrorKind, message: str) -> None:
|
|
17
|
+
super().__init__(f"{kind}: {message}")
|
|
18
|
+
self.kind: ErrorKind = kind
|
|
19
|
+
self.message = message
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Completion:
|
|
24
|
+
text: str
|
|
25
|
+
usage: Usage
|
|
26
|
+
model_reported: str | None
|
|
27
|
+
stop_reason: str | None
|
|
28
|
+
sent: dict[str, Any] = field(default_factory=dict) # parameters actually sent
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Backend(Protocol):
|
|
32
|
+
provider: str
|
|
33
|
+
|
|
34
|
+
def check_model(self, model: str) -> str | None:
|
|
35
|
+
"""None if the model is usable with these credentials, else the problem."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def count_input_tokens(self, model: str, system: str, messages: list[Message],
|
|
39
|
+
schema: dict[str, Any] | None) -> int | None:
|
|
40
|
+
"""Exact or upper-bound input tokens, or None when only the estimate is available."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def complete(self, model: str, system: str, messages: list[Message], *, max_tokens: int,
|
|
44
|
+
temperature: float | None, seed: int | None,
|
|
45
|
+
schema: dict[str, Any] | None) -> Completion: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ServerInfo:
|
|
50
|
+
"""What a self-hosted server says about a model, where it says it (§3, KB-036)."""
|
|
51
|
+
kind: str | None = None # "ollama", "vllm", "lmstudio", "llama.cpp"
|
|
52
|
+
context_length: int | None = None # the context the server serves the model with
|
|
53
|
+
digest: str | None = None # model digest and quantization, e.g. "sha256:… Q4_K_M"
|
|
54
|
+
local: bool | None = None # False: the server forwards it to a hosted service
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def private_address(host: str) -> bool:
|
|
58
|
+
"""True if `host` is, or resolves to, a loopback, link-local, private or otherwise
|
|
59
|
+
non-public address (IPv4, IPv6, IPv4-mapped). An unresolvable name counts as private:
|
|
60
|
+
refusing is the safe answer (08-app.md INV-11)."""
|
|
61
|
+
import ipaddress
|
|
62
|
+
import socket
|
|
63
|
+
|
|
64
|
+
name = host.strip("[]")
|
|
65
|
+
try:
|
|
66
|
+
addrs = [ipaddress.ip_address(name)]
|
|
67
|
+
except ValueError:
|
|
68
|
+
try:
|
|
69
|
+
infos = socket.getaddrinfo(name, None)
|
|
70
|
+
except OSError:
|
|
71
|
+
return True
|
|
72
|
+
addrs = [ipaddress.ip_address(str(i[4][0]).split("%")[0]) for i in infos]
|
|
73
|
+
for a in addrs:
|
|
74
|
+
mapped = getattr(a, "ipv4_mapped", None)
|
|
75
|
+
if mapped is not None:
|
|
76
|
+
a = mapped
|
|
77
|
+
if not a.is_global or a.is_multicast:
|
|
78
|
+
return True
|
|
79
|
+
return not addrs
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def scrub(text: str, secrets: list[str]) -> str:
|
|
83
|
+
"""Remove credential values from text that came from a provider or its SDK (INV-7)."""
|
|
84
|
+
for secret in secrets:
|
|
85
|
+
if secret:
|
|
86
|
+
text = text.replace(secret, "[REDACTED]")
|
|
87
|
+
return text[:500]
|