sp2react 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.
- sp2react/__init__.py +18 -0
- sp2react/agents/__init__.py +0 -0
- sp2react/agents/core.py +146 -0
- sp2react/agents/prompts/dbmap.md +28 -0
- sp2react/agents/prompts/fix.md +11 -0
- sp2react/agents/prompts/handler.md +22 -0
- sp2react/agents/prompts/page.md +40 -0
- sp2react/agents/prompts/review.md +21 -0
- sp2react/agents/prompts/spec.md +79 -0
- sp2react/agents/tasks.py +374 -0
- sp2react/analysis/__init__.py +0 -0
- sp2react/analysis/aspx.py +254 -0
- sp2react/analysis/behaviors.py +357 -0
- sp2react/analysis/fields.py +152 -0
- sp2react/analysis/inventory.py +314 -0
- sp2react/analysis/jsscan.py +330 -0
- sp2react/checks.py +105 -0
- sp2react/cli.py +188 -0
- sp2react/config.py +172 -0
- sp2react/dbmap.py +245 -0
- sp2react/llm/__init__.py +0 -0
- sp2react/llm/providers.py +219 -0
- sp2react/migrate.py +255 -0
- sp2react/net.py +60 -0
- sp2react/pipeline.py +583 -0
- sp2react/scaffold/__init__.py +0 -0
- sp2react/scaffold/generate.py +279 -0
- sp2react/scaffold/rule_vectors.json +3826 -0
- sp2react/scaffold/templates/backend/app/__init__.py +0 -0
- sp2react/scaffold/templates/backend/app/api.py +293 -0
- sp2react/scaffold/templates/backend/app/auth.py +33 -0
- sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
- sp2react/scaffold/templates/backend/app/main.py +47 -0
- sp2react/scaffold/templates/backend/app/odata.py +185 -0
- sp2react/scaffold/templates/backend/app/rules.py +256 -0
- sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
- sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
- sp2react/scaffold/templates/backend/app/store.py +224 -0
- sp2react/scaffold/templates/backend/requirements.txt +8 -0
- sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
- sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
- sp2react/scaffold/templates/frontend/index.html +12 -0
- sp2react/scaffold/templates/frontend/package.json +24 -0
- sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
- sp2react/scaffold/templates/frontend/src/api.ts +88 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
- sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
- sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
- sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
- sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
- sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
- sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
- sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
- sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
- sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
- sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
- sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
- sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
- sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
- sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
- sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
- sp2react/sharepoint/__init__.py +0 -0
- sp2react/sharepoint/client.py +210 -0
- sp2react/sharepoint/extract.py +217 -0
- sp2react/spec/__init__.py +0 -0
- sp2react/spec/base.py +80 -0
- sp2react/spec/coverage.py +191 -0
- sp2react/spec/model.py +133 -0
- sp2react-0.1.0.dist-info/METADATA +238 -0
- sp2react-0.1.0.dist-info/RECORD +79 -0
- sp2react-0.1.0.dist-info/WHEEL +5 -0
- sp2react-0.1.0.dist-info/entry_points.txt +2 -0
- sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
- sp2react-0.1.0.dist-info/top_level.txt +1 -0
sp2react/dbmap.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Map SharePoint lists onto an existing database (the data your team already migrated).
|
|
2
|
+
|
|
3
|
+
reflect read the database SCHEMA only (tables, columns, types, keys); no rows are read
|
|
4
|
+
draft deterministic first pass: names, types and foreign keys
|
|
5
|
+
agent the dbmap agent resolves what the draft could not (renamed or split columns, odd names)
|
|
6
|
+
validate every mapped table/column exists, types are compatible, required fields are mapped
|
|
7
|
+
The result is db-mapping.json plus db-mapping.md for you to review before code generation uses it.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
TYPE_FAMILIES = {
|
|
17
|
+
"text": ("char", "text", "string", "clob", "varchar", "nvarchar", "uniqueidentifier"),
|
|
18
|
+
"number": ("int", "dec", "num", "float", "double", "real", "money"),
|
|
19
|
+
"date": ("date", "time"),
|
|
20
|
+
"boolean": ("bool", "bit", "tinyint"),
|
|
21
|
+
"binary": ("blob", "binary", "image", "bytea"),
|
|
22
|
+
}
|
|
23
|
+
FIELD_FAMILY = {"text": "text", "note": "text", "choice": "text", "multichoice": "text", "url": "text", "calculated": "text",
|
|
24
|
+
"taxonomy": "text", "number": "number", "currency": "number", "date": "date", "datetime": "date",
|
|
25
|
+
"boolean": "boolean", "lookup": "number", "user": "any"}
|
|
26
|
+
SYSTEM_PATTERNS = {
|
|
27
|
+
"Created": r"^(created|created_?(at|on|date|dt|time)|create_?date|date_?created)$",
|
|
28
|
+
"Modified": r"^(modified|modified_?(at|on|date|dt|time)|updated_?(at|on)|last_?modified|date_?modified)$",
|
|
29
|
+
"Author": r"^(author|created_?by|creator)(_?id|_?login)?$",
|
|
30
|
+
"Editor": r"^(editor|modified_?by|updated_?by|last_?modified_?by)(_?id|_?login)?$",
|
|
31
|
+
"version": r"^(version|row_?version|ui_?version|rowversion|etag)$",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def tokens(name: str) -> set[str]:
|
|
36
|
+
s = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name)
|
|
37
|
+
s = re.sub(r"_x0020_", " ", s)
|
|
38
|
+
parts = [p for p in re.split(r"[^A-Za-z0-9]+", s.lower()) if p]
|
|
39
|
+
out = set()
|
|
40
|
+
for p in parts:
|
|
41
|
+
if p in ("tbl", "t", "sp", "list", "lst", "dbo"):
|
|
42
|
+
continue
|
|
43
|
+
out.add(p[:-1] if p.endswith("s") and len(p) > 3 else p)
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def similarity(a: str, b: str) -> float:
|
|
48
|
+
ta, tb = tokens(a), tokens(b)
|
|
49
|
+
if not ta or not tb:
|
|
50
|
+
return 0.0
|
|
51
|
+
return len(ta & tb) / len(ta | tb)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def family(sql_type: str) -> str:
|
|
55
|
+
t = sql_type.lower()
|
|
56
|
+
for fam, keys in TYPE_FAMILIES.items():
|
|
57
|
+
if any(k in t for k in keys):
|
|
58
|
+
return "boolean" if fam == "number" and "tinyint(1)" in t else fam
|
|
59
|
+
return "text"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compatible(field_type: str, sql_type: str) -> bool:
|
|
63
|
+
want = FIELD_FAMILY.get(field_type, "text")
|
|
64
|
+
have = family(sql_type)
|
|
65
|
+
if want == "any":
|
|
66
|
+
return have in ("number", "text")
|
|
67
|
+
if want == "boolean":
|
|
68
|
+
return have in ("boolean", "number")
|
|
69
|
+
if want == "date":
|
|
70
|
+
return have in ("date", "text")
|
|
71
|
+
return have == want or (want == "text" and have in ("text",)) or (want == "number" and have == "number")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------ reflect
|
|
75
|
+
def reflect(url: str, schema: Optional[str] = None) -> dict:
|
|
76
|
+
try:
|
|
77
|
+
from sqlalchemy import create_engine, inspect
|
|
78
|
+
except ImportError as e: # pragma: no cover - optional extra
|
|
79
|
+
raise RuntimeError("map-db needs SQLAlchemy and your driver: pip install 'sp2react[db]' (and pymysql or pyodbc)") from e
|
|
80
|
+
eng = create_engine(url, future=True)
|
|
81
|
+
ins = inspect(eng)
|
|
82
|
+
out: dict = {"dialect": eng.dialect.name, "schema": schema, "tables": {}}
|
|
83
|
+
for t in ins.get_table_names(schema=schema):
|
|
84
|
+
pk = ins.get_pk_constraint(t, schema=schema).get("constrained_columns") or []
|
|
85
|
+
fks = {}
|
|
86
|
+
for fk in ins.get_foreign_keys(t, schema=schema):
|
|
87
|
+
for c, rc in zip(fk.get("constrained_columns", []), fk.get("referred_columns", [])):
|
|
88
|
+
fks[c] = {"table": fk.get("referred_table"), "column": rc}
|
|
89
|
+
cols = [{"name": c["name"], "type": str(c["type"]), "nullable": bool(c.get("nullable", True)),
|
|
90
|
+
"pk": c["name"] in pk, **({"fk": fks[c["name"]]} if c["name"] in fks else {})}
|
|
91
|
+
for c in ins.get_columns(t, schema=schema)]
|
|
92
|
+
out["tables"][t] = {"columns": cols, "pk": pk}
|
|
93
|
+
eng.dispose()
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# -------------------------------------------------------------------- draft
|
|
98
|
+
def draft(lists: dict[str, dict], db: dict) -> tuple[dict, list[str]]:
|
|
99
|
+
"""lists: {ListKey: {"title", "fields": [...]}} -> (mapping, unresolved notes)."""
|
|
100
|
+
mapping: dict = {"version": 1, "schema": db.get("schema"), "lists": {}}
|
|
101
|
+
notes: list[str] = []
|
|
102
|
+
tables = db["tables"]
|
|
103
|
+
chosen_tables: dict[str, str] = {}
|
|
104
|
+
for key, l in lists.items():
|
|
105
|
+
scored = sorted(((max(similarity(key, t), similarity(l["title"], t)), t) for t in tables), reverse=True)
|
|
106
|
+
if not scored or scored[0][0] < 0.5:
|
|
107
|
+
notes.append(f"{key}: no table with a matching name (best: {scored[0][1] if scored else 'none'})")
|
|
108
|
+
continue
|
|
109
|
+
chosen_tables[key] = scored[0][1]
|
|
110
|
+
for key, tname in chosen_tables.items():
|
|
111
|
+
l, t = lists[key], tables[tname]
|
|
112
|
+
cols = {c["name"]: c for c in t["columns"]}
|
|
113
|
+
used: set[str] = set()
|
|
114
|
+
m: dict = {"table": tname, "key": (t["pk"][0] if t["pk"] else next((c for c in cols if c.lower() in ("id", "item_id", f"{tname}_id")), None)), "fields": {}, "system": {}}
|
|
115
|
+
if not m["key"]:
|
|
116
|
+
notes.append(f"{key}: table {tname} has no primary key; set 'key' to the column holding the item id")
|
|
117
|
+
used.add(m["key"])
|
|
118
|
+
for f in l["fields"]:
|
|
119
|
+
cands = []
|
|
120
|
+
for cname, c in cols.items():
|
|
121
|
+
if cname in used:
|
|
122
|
+
continue
|
|
123
|
+
s = max(similarity(f["name"], cname), similarity(f["label"], cname))
|
|
124
|
+
if f["type"] == "lookup":
|
|
125
|
+
target = chosen_tables.get(f["lookup"]["list"])
|
|
126
|
+
if c.get("fk", {}).get("table") == target and target:
|
|
127
|
+
s += 1.0
|
|
128
|
+
if cname.lower().endswith("_id") or cname.lower().endswith("id"):
|
|
129
|
+
s += 0.2
|
|
130
|
+
if f["type"] == "user" and re.search(r"(login|user|email|_id)$", cname.lower()):
|
|
131
|
+
s += 0.1
|
|
132
|
+
if s > 0 and compatible(f["type"], c["type"]):
|
|
133
|
+
cands.append((s, cname))
|
|
134
|
+
cands.sort(reverse=True)
|
|
135
|
+
if cands and cands[0][0] >= 0.5:
|
|
136
|
+
cname = cands[0][1]
|
|
137
|
+
used.add(cname)
|
|
138
|
+
spec: Any = cname
|
|
139
|
+
if f["type"] == "user" and family(cols[cname]["type"]) == "text":
|
|
140
|
+
spec = {"column": cname, "ref": "login"}
|
|
141
|
+
if f.get("multi") or f["type"] == "multichoice":
|
|
142
|
+
spec = {"column": cname, "delimiter": ";", **({"ref": "login"} if isinstance(spec, dict) else {})}
|
|
143
|
+
m["fields"][f["name"]] = spec
|
|
144
|
+
else:
|
|
145
|
+
notes.append(f"{key}.{f['name']} ({f['label']}, {f['type']}): no confident column match in {tname}")
|
|
146
|
+
for sysname, pat in SYSTEM_PATTERNS.items():
|
|
147
|
+
hit = next((c for c in cols if c not in used and re.match(pat, c.lower())), None)
|
|
148
|
+
if hit:
|
|
149
|
+
m["system"][sysname] = hit
|
|
150
|
+
used.add(hit)
|
|
151
|
+
mapping["lists"][key] = m
|
|
152
|
+
users = next((t for t in tables if re.search(r"(^|_)(app_?)?users?$", t.lower())), None)
|
|
153
|
+
if users:
|
|
154
|
+
cols = [c["name"] for c in tables[users]["columns"]]
|
|
155
|
+
pick = lambda pat: next((c for c in cols if re.search(pat, c.lower())), None)
|
|
156
|
+
um = {"table": users, "id": tables[users]["pk"][0] if tables[users]["pk"] else pick(r"^(user_?)?id$"),
|
|
157
|
+
"login": pick(r"(login|user_?name|account|upn|sam)"), "title": pick(r"(display|full_?name|^name$|title)"), "email": pick(r"mail")}
|
|
158
|
+
if um["id"] and um["login"]:
|
|
159
|
+
mapping["users"] = {k: v for k, v in um.items() if v}
|
|
160
|
+
return mapping, notes
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ----------------------------------------------------------------- validate
|
|
164
|
+
def validate(mapping: dict, lists: dict[str, dict], db: dict) -> tuple[list[str], list[str]]:
|
|
165
|
+
"""-> (problems that block, warnings)."""
|
|
166
|
+
problems, warnings = [], []
|
|
167
|
+
tables = db["tables"]
|
|
168
|
+
for key, l in lists.items():
|
|
169
|
+
m = mapping.get("lists", {}).get(key)
|
|
170
|
+
if not m:
|
|
171
|
+
problems.append(f"List {key} ({l['title']}) is not mapped to a table")
|
|
172
|
+
continue
|
|
173
|
+
t = tables.get(m.get("table"))
|
|
174
|
+
if not t:
|
|
175
|
+
problems.append(f"{key}: table '{m.get('table')}' does not exist")
|
|
176
|
+
continue
|
|
177
|
+
cols = {c["name"]: c for c in t["columns"]}
|
|
178
|
+
if m.get("key") not in cols:
|
|
179
|
+
problems.append(f"{key}: key column '{m.get('key')}' does not exist in {m['table']}")
|
|
180
|
+
for fname, spec in m.get("fields", {}).items():
|
|
181
|
+
cname = spec if isinstance(spec, str) else spec.get("column")
|
|
182
|
+
f = next((x for x in l["fields"] if x["name"] == fname), None)
|
|
183
|
+
if not f:
|
|
184
|
+
problems.append(f"{key}: '{fname}' is not a field of the list")
|
|
185
|
+
continue
|
|
186
|
+
if cname not in cols:
|
|
187
|
+
problems.append(f"{key}.{fname}: column '{cname}' does not exist in {m['table']}")
|
|
188
|
+
continue
|
|
189
|
+
if not compatible(f["type"], cols[cname]["type"]):
|
|
190
|
+
warnings.append(f"{key}.{fname}: {f['type']} field mapped to {cols[cname]['type']} column {cname}")
|
|
191
|
+
if f.get("required") and cols[cname]["nullable"] is False:
|
|
192
|
+
pass
|
|
193
|
+
if f["type"] == "lookup" and f["lookup"]["list"] not in mapping.get("lists", {}):
|
|
194
|
+
warnings.append(f"{key}.{fname}: looks up {f['lookup']['list']}, which is not mapped (titles will be empty)")
|
|
195
|
+
for f in l["fields"]:
|
|
196
|
+
if f["name"] not in m.get("fields", {}):
|
|
197
|
+
(problems if f.get("required") else warnings).append(
|
|
198
|
+
f"{key}.{f['name']} ({f['label']}{', required' if f.get('required') else ''}) has no column")
|
|
199
|
+
for sysname, cname in m.get("system", {}).items():
|
|
200
|
+
if cname not in cols:
|
|
201
|
+
problems.append(f"{key}: system column {sysname} -> '{cname}' does not exist")
|
|
202
|
+
for cname, c in cols.items():
|
|
203
|
+
mapped = {m.get("key")} | {(s if isinstance(s, str) else s.get("column")) for s in m.get("fields", {}).values()} | set(m.get("system", {}).values())
|
|
204
|
+
if cname not in mapped and not c["nullable"] and not c["pk"]:
|
|
205
|
+
warnings.append(f"{key}: NOT NULL column {m['table']}.{cname} is not mapped; inserts will fail unless it has a default")
|
|
206
|
+
um = mapping.get("users")
|
|
207
|
+
if um:
|
|
208
|
+
t = tables.get(um.get("table"))
|
|
209
|
+
if not t:
|
|
210
|
+
problems.append(f"users: table '{um.get('table')}' does not exist")
|
|
211
|
+
else:
|
|
212
|
+
cols = {c["name"] for c in t["columns"]}
|
|
213
|
+
for k in ("id", "login"):
|
|
214
|
+
if um.get(k) not in cols:
|
|
215
|
+
problems.append(f"users: {k} column '{um.get(k)}' does not exist")
|
|
216
|
+
return problems, warnings
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def report(mapping: dict, lists: dict[str, dict], db: dict, problems: list[str], warnings: list[str]) -> str:
|
|
220
|
+
lines = ["# Database mapping: SharePoint lists -> existing tables", "",
|
|
221
|
+
f"Database: {db.get('dialect')} {('schema ' + db['schema']) if db.get('schema') else ''}. Only the schema was read; no rows.", "",
|
|
222
|
+
f"**{'Ready' if not problems else 'Needs attention'}**: {len(problems)} problem(s), {len(warnings)} warning(s).", ""]
|
|
223
|
+
for key, l in lists.items():
|
|
224
|
+
m = mapping.get("lists", {}).get(key)
|
|
225
|
+
lines += [f"## {l['title']} ({key}) -> `{m['table'] if m else 'not mapped'}`", ""]
|
|
226
|
+
if not m:
|
|
227
|
+
continue
|
|
228
|
+
cols = {c["name"]: c for c in db["tables"].get(m["table"], {}).get("columns", [])}
|
|
229
|
+
lines += [f"Key: `{m.get('key')}`", "", "| Field | Type | Column | Column type |", "|---|---|---|---|"]
|
|
230
|
+
for f in l["fields"]:
|
|
231
|
+
spec = m.get("fields", {}).get(f["name"])
|
|
232
|
+
cname = spec if isinstance(spec, str) else (spec or {}).get("column")
|
|
233
|
+
extra = f" ({', '.join(f'{k}={v}' for k, v in spec.items() if k != 'column')})" if isinstance(spec, dict) else ""
|
|
234
|
+
lines.append(f"| {f['label']} (`{f['name']}`) | {f['type']} | {('`' + cname + '`' + extra) if cname else '**missing**'} | {cols.get(cname, {}).get('type', '')} |")
|
|
235
|
+
if m.get("system"):
|
|
236
|
+
lines += ["", "System columns: " + ", ".join(f"{k} -> `{v}`" for k, v in m["system"].items())]
|
|
237
|
+
lines.append("")
|
|
238
|
+
if mapping.get("users"):
|
|
239
|
+
u = mapping["users"]
|
|
240
|
+
lines += ["## People", "", f"Users table `{u['table']}` (id `{u['id']}`, login `{u['login']}`" + (f", name `{u['title']}`" if u.get("title") else "") + ")", ""]
|
|
241
|
+
if problems:
|
|
242
|
+
lines += ["## Problems (block code generation)", ""] + [f"- {p}" for p in problems] + [""]
|
|
243
|
+
if warnings:
|
|
244
|
+
lines += ["## Warnings", ""] + [f"- {w}" for w in warnings]
|
|
245
|
+
return "\n".join(lines) + "\n"
|
sp2react/llm/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Model providers. Bridger (GitHub Copilot bridge) serving Claude is the default.
|
|
2
|
+
|
|
3
|
+
Two wire formats cover every provider:
|
|
4
|
+
* Anthropic Messages (POST {base}/v1/messages) -> anthropic, bridger(api_format=anthropic)
|
|
5
|
+
* OpenAI Chat (POST {base}/v1/chat/completions) -> openai, bridger(api_format=openai), azure_openai
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import random
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Callable, Optional
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from ..config import ConfigError, LLMConfig, secret
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
RETRY_STATUS = {408, 409, 425, 429, 500, 502, 503, 504, 529}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LLMError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Completion:
|
|
30
|
+
text: str
|
|
31
|
+
model: str = ""
|
|
32
|
+
input_tokens: int = 0
|
|
33
|
+
output_tokens: int = 0
|
|
34
|
+
truncated: bool = False # stopped because of max_tokens
|
|
35
|
+
raw: dict = field(default_factory=dict, repr=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LLM:
|
|
39
|
+
name = "base"
|
|
40
|
+
|
|
41
|
+
def __init__(self, cfg: LLMConfig):
|
|
42
|
+
self.cfg = cfg
|
|
43
|
+
self.policy = None
|
|
44
|
+
self._local = threading.local()
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def session(self) -> requests.Session:
|
|
48
|
+
s = getattr(self._local, "session", None)
|
|
49
|
+
if s is None:
|
|
50
|
+
s = requests.Session()
|
|
51
|
+
s.verify = self.cfg.verify_ssl
|
|
52
|
+
if self.policy is not None:
|
|
53
|
+
self.policy.guard(s)
|
|
54
|
+
self._local.session = s
|
|
55
|
+
return s
|
|
56
|
+
|
|
57
|
+
def _post(self, url: str, headers: dict, body: dict, timeout: Optional[int]) -> dict:
|
|
58
|
+
delay = 2.0
|
|
59
|
+
for attempt in range(5):
|
|
60
|
+
try:
|
|
61
|
+
r = self.session.post(url, headers=headers, json=body, timeout=timeout or self.cfg.timeout)
|
|
62
|
+
except requests.RequestException as e:
|
|
63
|
+
if attempt == 4:
|
|
64
|
+
raise LLMError(f"{self.name}: request failed: {e}") from e
|
|
65
|
+
log.warning("%s: %s, retrying in %.0fs", self.name, e, delay)
|
|
66
|
+
else:
|
|
67
|
+
if r.status_code < 400:
|
|
68
|
+
try:
|
|
69
|
+
return r.json()
|
|
70
|
+
except ValueError as e:
|
|
71
|
+
raise LLMError(f"{self.name}: non-JSON response: {r.text[:300]}") from e
|
|
72
|
+
if r.status_code not in RETRY_STATUS or attempt == 4:
|
|
73
|
+
raise LLMError(f"{self.name}: HTTP {r.status_code}: {r.text[:500]}")
|
|
74
|
+
ra = r.headers.get("retry-after")
|
|
75
|
+
if ra and ra.replace(".", "", 1).isdigit():
|
|
76
|
+
delay = max(delay, float(ra))
|
|
77
|
+
log.warning("%s: HTTP %s, retrying in %.0fs", self.name, r.status_code, delay)
|
|
78
|
+
time.sleep(delay + random.random())
|
|
79
|
+
delay = min(delay * 2, 60)
|
|
80
|
+
raise LLMError(f"{self.name}: gave up") # pragma: no cover
|
|
81
|
+
|
|
82
|
+
def complete(self, system: str, messages: list[dict], *, max_tokens: Optional[int] = None,
|
|
83
|
+
json_output: bool = False, timeout: Optional[int] = None) -> Completion:
|
|
84
|
+
raise NotImplementedError
|
|
85
|
+
|
|
86
|
+
def models(self) -> list[str]:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _key(cfg: LLMConfig, required: bool) -> Optional[str]:
|
|
91
|
+
return secret(cfg.api_key_env, "model API key/token", required=required) if cfg.api_key_env else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AnthropicMessages(LLM):
|
|
95
|
+
"""Anthropic Messages API: api.anthropic.com, or Bridger serving Claude in Anthropic format."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, cfg: LLMConfig, base_url: str, name: str, bearer: bool):
|
|
98
|
+
super().__init__(cfg)
|
|
99
|
+
self.base = base_url.rstrip("/").removesuffix("/v1")
|
|
100
|
+
self.name = name
|
|
101
|
+
self.key = _key(cfg, required=not bearer)
|
|
102
|
+
self.bearer = bearer
|
|
103
|
+
if not cfg.model:
|
|
104
|
+
raise ConfigError(f"{name}: set llm.model (run `sp2react doctor` to list models)")
|
|
105
|
+
|
|
106
|
+
def _headers(self) -> dict:
|
|
107
|
+
h = {"content-type": "application/json", "anthropic-version": "2023-06-01", **self.cfg.extra_headers}
|
|
108
|
+
if self.key:
|
|
109
|
+
h["x-api-key"] = self.key
|
|
110
|
+
if self.bearer:
|
|
111
|
+
h["authorization"] = f"Bearer {self.key}"
|
|
112
|
+
return h
|
|
113
|
+
|
|
114
|
+
def complete(self, system, messages, *, max_tokens=None, json_output=False, timeout=None) -> Completion:
|
|
115
|
+
body = {"model": self.cfg.model, "max_tokens": max_tokens or self.cfg.max_tokens, "system": system,
|
|
116
|
+
"messages": [{"role": m["role"], "content": m["content"]} for m in messages],
|
|
117
|
+
"temperature": self.cfg.temperature}
|
|
118
|
+
data = self._post(f"{self.base}/v1/messages", self._headers(), body, timeout)
|
|
119
|
+
text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
|
|
120
|
+
u = data.get("usage") or {}
|
|
121
|
+
return Completion(text=text, model=data.get("model", self.cfg.model or ""), input_tokens=u.get("input_tokens", 0),
|
|
122
|
+
output_tokens=u.get("output_tokens", 0), truncated=data.get("stop_reason") == "max_tokens", raw=data)
|
|
123
|
+
|
|
124
|
+
def models(self) -> list[str]:
|
|
125
|
+
r = self.session.get(f"{self.base}/v1/models", headers=self._headers(), timeout=30)
|
|
126
|
+
r.raise_for_status()
|
|
127
|
+
return [m.get("id") for m in r.json().get("data", [])]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class OpenAIChat(LLM):
|
|
131
|
+
"""OpenAI-style chat completions: OpenAI, Bridger in OpenAI format, Azure OpenAI."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, cfg: LLMConfig, base_url: str, name: str, azure: bool = False, key_required: bool = True):
|
|
134
|
+
super().__init__(cfg)
|
|
135
|
+
self.name = name
|
|
136
|
+
self.azure = azure
|
|
137
|
+
self.key = _key(cfg, required=key_required)
|
|
138
|
+
base = base_url.rstrip("/")
|
|
139
|
+
if azure:
|
|
140
|
+
if not cfg.deployment:
|
|
141
|
+
raise ConfigError("azure_openai: set llm.deployment")
|
|
142
|
+
self.url = f"{base}/openai/deployments/{cfg.deployment}/chat/completions?api-version={cfg.api_version}"
|
|
143
|
+
self.models_url = None
|
|
144
|
+
else:
|
|
145
|
+
if not cfg.model:
|
|
146
|
+
raise ConfigError(f"{name}: set llm.model (run `sp2react doctor` to list models)")
|
|
147
|
+
root = base if base.endswith("/v1") else base + "/v1"
|
|
148
|
+
self.url, self.models_url = f"{root}/chat/completions", f"{root}/models"
|
|
149
|
+
|
|
150
|
+
def _headers(self) -> dict:
|
|
151
|
+
h = {"content-type": "application/json", **self.cfg.extra_headers}
|
|
152
|
+
if self.key:
|
|
153
|
+
h["api-key" if self.azure else "authorization"] = self.key if self.azure else f"Bearer {self.key}"
|
|
154
|
+
return h
|
|
155
|
+
|
|
156
|
+
def complete(self, system, messages, *, max_tokens=None, json_output=False, timeout=None) -> Completion:
|
|
157
|
+
body = {"messages": [{"role": "system", "content": system}] + messages,
|
|
158
|
+
"max_tokens": max_tokens or self.cfg.max_tokens, "temperature": self.cfg.temperature}
|
|
159
|
+
if not self.azure:
|
|
160
|
+
body["model"] = self.cfg.model
|
|
161
|
+
if json_output and self.cfg.json_mode:
|
|
162
|
+
body["response_format"] = {"type": "json_object"}
|
|
163
|
+
data = self._post(self.url, self._headers(), body, timeout)
|
|
164
|
+
choice = (data.get("choices") or [{}])[0]
|
|
165
|
+
u = data.get("usage") or {}
|
|
166
|
+
return Completion(text=(choice.get("message") or {}).get("content") or "", model=data.get("model", self.cfg.model or ""),
|
|
167
|
+
input_tokens=u.get("prompt_tokens", 0), output_tokens=u.get("completion_tokens", 0),
|
|
168
|
+
truncated=choice.get("finish_reason") == "length", raw=data)
|
|
169
|
+
|
|
170
|
+
def models(self) -> list[str]:
|
|
171
|
+
if not self.models_url:
|
|
172
|
+
return [self.cfg.deployment or ""]
|
|
173
|
+
r = self.session.get(self.models_url, headers=self._headers(), timeout=30)
|
|
174
|
+
r.raise_for_status()
|
|
175
|
+
return [m.get("id") for m in r.json().get("data", [])]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ScriptedLLM(LLM):
|
|
179
|
+
"""Deterministic stand-in for tests and dry runs: responder(agent, system, messages) -> text."""
|
|
180
|
+
name = "scripted"
|
|
181
|
+
|
|
182
|
+
def __init__(self, responder: Callable[[str, str, list[dict]], str], cfg: Optional[LLMConfig] = None):
|
|
183
|
+
super().__init__(cfg or LLMConfig(provider="scripted"))
|
|
184
|
+
self.responder = responder
|
|
185
|
+
self.calls: list[dict] = []
|
|
186
|
+
self.agent = ""
|
|
187
|
+
|
|
188
|
+
def complete(self, system, messages, *, max_tokens=None, json_output=False, timeout=None) -> Completion:
|
|
189
|
+
agent = self.agent
|
|
190
|
+
for line in system.splitlines()[:3]:
|
|
191
|
+
if line.startswith("<agent name=\""):
|
|
192
|
+
agent = line.split('"')[1]
|
|
193
|
+
self.calls.append({"agent": agent, "messages": messages})
|
|
194
|
+
return Completion(text=self.responder(agent, system, messages), model="scripted")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def make_llm(cfg: LLMConfig, policy=None) -> LLM:
|
|
198
|
+
llm = _make(cfg)
|
|
199
|
+
llm.policy = policy
|
|
200
|
+
return llm
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _make(cfg: LLMConfig) -> LLM:
|
|
204
|
+
p = cfg.provider
|
|
205
|
+
if p == "bridger":
|
|
206
|
+
if not cfg.base_url:
|
|
207
|
+
raise ConfigError("bridger: set llm.base_url to the Bridger endpoint, e.g. http://localhost:4141")
|
|
208
|
+
if cfg.api_format == "anthropic":
|
|
209
|
+
return AnthropicMessages(cfg, cfg.base_url, "bridger", bearer=True)
|
|
210
|
+
return OpenAIChat(cfg, cfg.base_url, "bridger", key_required=False)
|
|
211
|
+
if p == "anthropic":
|
|
212
|
+
return AnthropicMessages(cfg, cfg.base_url or "https://api.anthropic.com", "anthropic", bearer=False)
|
|
213
|
+
if p == "openai":
|
|
214
|
+
return OpenAIChat(cfg, cfg.base_url or "https://api.openai.com/v1", "openai")
|
|
215
|
+
if p == "azure_openai":
|
|
216
|
+
if not cfg.base_url:
|
|
217
|
+
raise ConfigError("azure_openai: set llm.base_url to https://<resource>.openai.azure.com")
|
|
218
|
+
return OpenAIChat(cfg, cfg.base_url, "azure_openai", azure=True)
|
|
219
|
+
raise ConfigError("provider 'scripted' is only for tests; construct ScriptedLLM directly")
|