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.
Files changed (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. 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
+ ]
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]