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.
Files changed (79) hide show
  1. sp2react/__init__.py +18 -0
  2. sp2react/agents/__init__.py +0 -0
  3. sp2react/agents/core.py +146 -0
  4. sp2react/agents/prompts/dbmap.md +28 -0
  5. sp2react/agents/prompts/fix.md +11 -0
  6. sp2react/agents/prompts/handler.md +22 -0
  7. sp2react/agents/prompts/page.md +40 -0
  8. sp2react/agents/prompts/review.md +21 -0
  9. sp2react/agents/prompts/spec.md +79 -0
  10. sp2react/agents/tasks.py +374 -0
  11. sp2react/analysis/__init__.py +0 -0
  12. sp2react/analysis/aspx.py +254 -0
  13. sp2react/analysis/behaviors.py +357 -0
  14. sp2react/analysis/fields.py +152 -0
  15. sp2react/analysis/inventory.py +314 -0
  16. sp2react/analysis/jsscan.py +330 -0
  17. sp2react/checks.py +105 -0
  18. sp2react/cli.py +188 -0
  19. sp2react/config.py +172 -0
  20. sp2react/dbmap.py +245 -0
  21. sp2react/llm/__init__.py +0 -0
  22. sp2react/llm/providers.py +219 -0
  23. sp2react/migrate.py +255 -0
  24. sp2react/net.py +60 -0
  25. sp2react/pipeline.py +583 -0
  26. sp2react/scaffold/__init__.py +0 -0
  27. sp2react/scaffold/generate.py +279 -0
  28. sp2react/scaffold/rule_vectors.json +3826 -0
  29. sp2react/scaffold/templates/backend/app/__init__.py +0 -0
  30. sp2react/scaffold/templates/backend/app/api.py +293 -0
  31. sp2react/scaffold/templates/backend/app/auth.py +33 -0
  32. sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
  33. sp2react/scaffold/templates/backend/app/main.py +47 -0
  34. sp2react/scaffold/templates/backend/app/odata.py +185 -0
  35. sp2react/scaffold/templates/backend/app/rules.py +256 -0
  36. sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
  37. sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
  38. sp2react/scaffold/templates/backend/app/store.py +224 -0
  39. sp2react/scaffold/templates/backend/requirements.txt +8 -0
  40. sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
  41. sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
  42. sp2react/scaffold/templates/frontend/index.html +12 -0
  43. sp2react/scaffold/templates/frontend/package.json +24 -0
  44. sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
  45. sp2react/scaffold/templates/frontend/src/api.ts +88 -0
  46. sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
  47. sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
  48. sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
  49. sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
  50. sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
  51. sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
  52. sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
  53. sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
  54. sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
  55. sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
  56. sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
  57. sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
  58. sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
  59. sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
  60. sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
  61. sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
  62. sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
  63. sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
  64. sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
  65. sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
  66. sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
  67. sp2react/sharepoint/__init__.py +0 -0
  68. sp2react/sharepoint/client.py +210 -0
  69. sp2react/sharepoint/extract.py +217 -0
  70. sp2react/spec/__init__.py +0 -0
  71. sp2react/spec/base.py +80 -0
  72. sp2react/spec/coverage.py +191 -0
  73. sp2react/spec/model.py +133 -0
  74. sp2react-0.1.0.dist-info/METADATA +238 -0
  75. sp2react-0.1.0.dist-info/RECORD +79 -0
  76. sp2react-0.1.0.dist-info/WHEEL +5 -0
  77. sp2react-0.1.0.dist-info/entry_points.txt +2 -0
  78. sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. 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"
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")