stepcheck-dev 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.
- backend/__init__.py +0 -0
- backend/audit.py +351 -0
- backend/cli.py +274 -0
- backend/kernel/__init__.py +614 -0
- backend/kernel/_gates.py +299 -0
- backend/kernel/_okf.py +663 -0
- backend/kernel/_sandbox.py +135 -0
- backend/kernel/_telemetry.py +52 -0
- backend/kernel/_tokens.py +7 -0
- backend/server.py +584 -0
- stepcheck_dev-0.1.0.dist-info/METADATA +123 -0
- stepcheck_dev-0.1.0.dist-info/RECORD +17 -0
- stepcheck_dev-0.1.0.dist-info/WHEEL +5 -0
- stepcheck_dev-0.1.0.dist-info/entry_points.txt +2 -0
- stepcheck_dev-0.1.0.dist-info/licenses/LICENSE +202 -0
- stepcheck_dev-0.1.0.dist-info/licenses/NOTICE +4 -0
- stepcheck_dev-0.1.0.dist-info/top_level.txt +1 -0
backend/server.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"""DualDoorGateway — the whole HTTP surface behind Seam 2 (see GLOSSARY.md).
|
|
2
|
+
|
|
3
|
+
Door 1 (TokenFirewall proxy): POST /v1/chat/completions · POST /v1/messages.
|
|
4
|
+
Intercepts bloated `messages[]`, commits the latest verified code step into the
|
|
5
|
+
StateKernel, parks dead-end exploration turns as drafts, injects the compact
|
|
6
|
+
CheckoutPayload, and forwards upstream with the caller's own BYOK key — or
|
|
7
|
+
answers a deterministic local dry-run when no upstream is configured.
|
|
8
|
+
|
|
9
|
+
Door 2 (explicit surface): REST /v1/step/*, Streamable HTTP MCP at /mcp
|
|
10
|
+
(FastMCP, sharing the FastAPI lifespan — no legacy /sse), and
|
|
11
|
+
WS /v1/ws/{session_id} for real-time UI state streaming.
|
|
12
|
+
|
|
13
|
+
Tested only at this boundary (tests/test_seam2_gateway.py); kernel internals
|
|
14
|
+
are Seam 1's business.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import dataclasses
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import uuid
|
|
23
|
+
from contextlib import asynccontextmanager
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
31
|
+
from fastapi.responses import RedirectResponse
|
|
32
|
+
from fastapi.staticfiles import StaticFiles
|
|
33
|
+
from mcp.server.fastmcp import FastMCP
|
|
34
|
+
from pydantic import BaseModel
|
|
35
|
+
|
|
36
|
+
from kernel import StateKernel
|
|
37
|
+
|
|
38
|
+
KERNEL = StateKernel()
|
|
39
|
+
|
|
40
|
+
# Compiled Studio UI (frontend/dist, built with Vite base "/studio/"). Mounted
|
|
41
|
+
# at /studio when present so one port serves UI + API + MCP + WS.
|
|
42
|
+
STUDIO_DIST = Path(
|
|
43
|
+
os.environ.get("STEPCHECK_STUDIO_DIST", Path(__file__).resolve().parents[1] / "frontend" / "dist")
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_CODE_RE = re.compile(r"```python\n(.*?)```", re.S)
|
|
47
|
+
_PATCH_RE = re.compile(r"```stepcheck-patch\n(.*?)```", re.S)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _count(text: str) -> int:
|
|
51
|
+
return math.ceil(len(text) / 4)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --------------------------------------------------------------- WS streaming
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _WsManager:
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
self._conns: dict[str, list[WebSocket]] = {}
|
|
60
|
+
|
|
61
|
+
async def connect(self, session_id: str, ws: WebSocket) -> None:
|
|
62
|
+
await ws.accept()
|
|
63
|
+
self._conns.setdefault(session_id, []).append(ws)
|
|
64
|
+
|
|
65
|
+
def drop(self, session_id: str, ws: WebSocket) -> None:
|
|
66
|
+
conns = self._conns.get(session_id, [])
|
|
67
|
+
if ws in conns:
|
|
68
|
+
conns.remove(ws)
|
|
69
|
+
|
|
70
|
+
async def broadcast(self, session_id: str, message: dict) -> None:
|
|
71
|
+
for ws in list(self._conns.get(session_id, [])):
|
|
72
|
+
try:
|
|
73
|
+
await ws.send_json(message)
|
|
74
|
+
except Exception:
|
|
75
|
+
self.drop(session_id, ws)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
WS = _WsManager()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _telemetry_json(session_id: str) -> dict:
|
|
82
|
+
return dataclasses.asdict(KERNEL.get_telemetry(session_id))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _verdict_json(verdict) -> dict:
|
|
86
|
+
return {
|
|
87
|
+
"accepted": verdict.accepted,
|
|
88
|
+
"gate": verdict.gate,
|
|
89
|
+
"error": str(verdict.error) if verdict.error else None,
|
|
90
|
+
"subtype": getattr(verdict.error, "subtype", None),
|
|
91
|
+
"attestation": verdict.attestation,
|
|
92
|
+
"dirtied": verdict.dirtied,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def _broadcast_mutation(session_id: str, gate_event: dict | None = None) -> None:
|
|
97
|
+
if gate_event is not None:
|
|
98
|
+
await WS.broadcast(session_id, {"type": "gate_event", **gate_event})
|
|
99
|
+
await WS.broadcast(session_id, {"type": "graph", "graph": KERNEL.graph(session_id)})
|
|
100
|
+
await WS.broadcast(
|
|
101
|
+
session_id, {"type": "telemetry", "telemetry": _telemetry_json(session_id)}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------- Door 2: Streamable HTTP MCP
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _build_mcp() -> FastMCP:
|
|
109
|
+
"""Fresh FastMCP per app startup — its StreamableHTTPSessionManager only
|
|
110
|
+
runs once per instance, and the FastAPI lifespan may re-enter (tests,
|
|
111
|
+
reloads)."""
|
|
112
|
+
m = FastMCP(
|
|
113
|
+
"stepcheck-dev",
|
|
114
|
+
stateless_http=True,
|
|
115
|
+
streamable_http_path="/",
|
|
116
|
+
instructions=(
|
|
117
|
+
"Anti-context-rot state engine for coding agents: checkout a compact "
|
|
118
|
+
"OKF frame, commit verified steps through the 3-tier gates, lock "
|
|
119
|
+
"invariants, watch omission risks, and read token savings."
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@m.tool()
|
|
124
|
+
async def init_project_session(project_root: str) -> dict[str, Any]:
|
|
125
|
+
"""Index a Python project into an OKF graph and open a session."""
|
|
126
|
+
session_id = KERNEL.init_session(project_root)
|
|
127
|
+
return {"session_id": session_id, "graph": KERNEL.graph(session_id)}
|
|
128
|
+
|
|
129
|
+
@m.tool()
|
|
130
|
+
async def checkout_context_frame(
|
|
131
|
+
session_id: str, node_id: str, focus: list[str] | None = None
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
"""Bounded stateless checkout: the node's frame + dependency interface
|
|
134
|
+
skeletons. `focus=[names]` slices the target to those defs (verbatim)
|
|
135
|
+
plus a skeleton of the rest; `focus=[]` is a pure skeleton."""
|
|
136
|
+
try:
|
|
137
|
+
payload = KERNEL.checkout(session_id, node_id, focus=focus)
|
|
138
|
+
except KeyError as exc:
|
|
139
|
+
raise ValueError(f"unknown session or node: {exc}")
|
|
140
|
+
return {
|
|
141
|
+
"session_id": session_id,
|
|
142
|
+
"node_id": node_id,
|
|
143
|
+
"rendered": payload.rendered(),
|
|
144
|
+
"dependency_signatures": payload.dependency_signatures,
|
|
145
|
+
"locked_invariants": payload.locked_invariants,
|
|
146
|
+
"token_count": payload.token_count,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@m.tool()
|
|
150
|
+
async def commit_verified_step(
|
|
151
|
+
session_id: str,
|
|
152
|
+
node_id: str,
|
|
153
|
+
source: str | None = None,
|
|
154
|
+
patch: list[dict[str, Any]] | None = None,
|
|
155
|
+
allow_deletion: bool = False,
|
|
156
|
+
) -> dict[str, Any]:
|
|
157
|
+
"""Run a staged `source` — or a `patch` of ops folded into canonical
|
|
158
|
+
state ({"op":"append"|"replace_symbol"|"delete_symbol", …}) — through
|
|
159
|
+
Gates A/B/C; attest on pass."""
|
|
160
|
+
verdict = KERNEL.commit(
|
|
161
|
+
session_id, node_id, source, patch=patch, allow_deletion=allow_deletion
|
|
162
|
+
)
|
|
163
|
+
out = _verdict_json(verdict)
|
|
164
|
+
await _broadcast_mutation(session_id, gate_event={"node_id": node_id, **out})
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
@m.tool()
|
|
168
|
+
async def lock_module_invariants(session_id: str, node_id: str) -> dict[str, Any]:
|
|
169
|
+
"""Pin a verified module: every further mutation is rejected at Gate A."""
|
|
170
|
+
KERNEL.lock_node(session_id, node_id)
|
|
171
|
+
await _broadcast_mutation(session_id)
|
|
172
|
+
return {"node_id": node_id, "status": "LOCKED", "locked": True}
|
|
173
|
+
|
|
174
|
+
@m.tool()
|
|
175
|
+
async def check_omission_risks(session_id: str) -> dict[str, Any]:
|
|
176
|
+
"""Nodes holding DIRTY_OMISSION_RISK and whether the session can finalize."""
|
|
177
|
+
graph = KERNEL.graph(session_id)
|
|
178
|
+
dirty = [
|
|
179
|
+
n["node_id"] for n in graph["nodes"] if n["status"] == "DIRTY_OMISSION_RISK"
|
|
180
|
+
]
|
|
181
|
+
return {"dirty": dirty, "can_finalize": not dirty}
|
|
182
|
+
|
|
183
|
+
@m.tool()
|
|
184
|
+
async def get_token_savings(session_id: str) -> dict[str, Any]:
|
|
185
|
+
"""raw_bloated_tokens vs compacted_okf_tokens and dollars saved."""
|
|
186
|
+
return _telemetry_json(session_id)
|
|
187
|
+
|
|
188
|
+
return m
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _McpMountProxy:
|
|
192
|
+
"""Stable mount point whose inner ASGI app is swapped on each startup."""
|
|
193
|
+
|
|
194
|
+
def __init__(self) -> None:
|
|
195
|
+
self.inner = None
|
|
196
|
+
|
|
197
|
+
async def __call__(self, scope, receive, send):
|
|
198
|
+
if self.inner is None: # pragma: no cover — requests before startup
|
|
199
|
+
raise RuntimeError("MCP app not started")
|
|
200
|
+
await self.inner(scope, receive, send)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_MCP_MOUNT = _McpMountProxy()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ----------------------------------------------------------------- FastAPI app
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@asynccontextmanager
|
|
210
|
+
async def lifespan(app: FastAPI):
|
|
211
|
+
# The MCP session manager shares the FastAPI lifespan (streamable-http).
|
|
212
|
+
m = _build_mcp()
|
|
213
|
+
_MCP_MOUNT.inner = m.streamable_http_app()
|
|
214
|
+
async with m.session_manager.run():
|
|
215
|
+
yield
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
app = FastAPI(title="StepCheck DualDoorGateway", lifespan=lifespan)
|
|
219
|
+
app.mount("/mcp", _MCP_MOUNT)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------ Door 2: REST
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class InitBody(BaseModel):
|
|
226
|
+
project_root: str
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class NodeBody(BaseModel):
|
|
230
|
+
session_id: str
|
|
231
|
+
node_id: str
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class CheckoutBody(NodeBody):
|
|
235
|
+
focus: list[str] | None = None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class CommitBody(NodeBody):
|
|
239
|
+
source: str | None = None
|
|
240
|
+
patch: list[dict] | None = None
|
|
241
|
+
allow_deletion: bool = False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _or_404(fn, *args, **kwargs):
|
|
245
|
+
try:
|
|
246
|
+
return fn(*args, **kwargs)
|
|
247
|
+
except KeyError as exc:
|
|
248
|
+
raise HTTPException(status_code=404, detail=f"unknown session or node: {exc}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@app.post("/v1/step/init")
|
|
252
|
+
async def step_init(body: InitBody) -> dict:
|
|
253
|
+
session_id = KERNEL.init_session(body.project_root)
|
|
254
|
+
return {"session_id": session_id, **KERNEL.graph(session_id)}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@app.post("/v1/step/checkout")
|
|
258
|
+
async def step_checkout(body: CheckoutBody) -> dict:
|
|
259
|
+
payload = _or_404(KERNEL.checkout, body.session_id, body.node_id, focus=body.focus)
|
|
260
|
+
return {
|
|
261
|
+
"session_id": body.session_id,
|
|
262
|
+
"node_id": body.node_id,
|
|
263
|
+
"frame_markdown": payload.frame_markdown,
|
|
264
|
+
"dependency_signatures": payload.dependency_signatures,
|
|
265
|
+
"locked_invariants": payload.locked_invariants,
|
|
266
|
+
"token_count": payload.token_count,
|
|
267
|
+
"rendered": payload.rendered(),
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@app.post("/v1/step/commit")
|
|
272
|
+
async def step_commit(body: CommitBody) -> dict:
|
|
273
|
+
verdict = _or_404(
|
|
274
|
+
KERNEL.commit,
|
|
275
|
+
body.session_id,
|
|
276
|
+
body.node_id,
|
|
277
|
+
body.source,
|
|
278
|
+
patch=body.patch,
|
|
279
|
+
allow_deletion=body.allow_deletion,
|
|
280
|
+
)
|
|
281
|
+
out = _verdict_json(verdict)
|
|
282
|
+
await _broadcast_mutation(body.session_id, gate_event={"node_id": body.node_id, **out})
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@app.post("/v1/step/lock")
|
|
287
|
+
async def step_lock(body: NodeBody) -> dict:
|
|
288
|
+
_or_404(KERNEL.lock_node, body.session_id, body.node_id)
|
|
289
|
+
await _broadcast_mutation(body.session_id)
|
|
290
|
+
return {"node_id": body.node_id, "status": "LOCKED", "locked": True}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@app.get("/v1/step/graph/{session_id}")
|
|
294
|
+
async def step_graph(session_id: str) -> dict:
|
|
295
|
+
return _or_404(KERNEL.graph, session_id)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@app.get("/v1/step/telemetry/{session_id}")
|
|
299
|
+
async def step_telemetry(session_id: str) -> dict:
|
|
300
|
+
return _or_404(_telemetry_json, session_id)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.get("/v1/step/drafts/{session_id}")
|
|
304
|
+
async def step_drafts(session_id: str) -> dict:
|
|
305
|
+
drafts = _or_404(KERNEL.drafts, session_id)
|
|
306
|
+
return {"count": len(drafts), "drafts": drafts}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@app.post("/v1/step/finalize")
|
|
310
|
+
async def step_finalize(body: InitBody | None = None, session_id: str | None = None) -> dict:
|
|
311
|
+
sid = session_id or (body and body.project_root)
|
|
312
|
+
raise HTTPException(status_code=400, detail="use POST /v1/step/finalize/{session_id}")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@app.post("/v1/step/finalize/{session_id}")
|
|
316
|
+
async def step_finalize_sid(session_id: str) -> dict:
|
|
317
|
+
from kernel import OmissionError
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
_or_404(KERNEL.finalize_session, session_id)
|
|
321
|
+
except OmissionError as exc:
|
|
322
|
+
return {"finalized": False, "error": str(exc), "subtype": exc.subtype}
|
|
323
|
+
return {"finalized": True}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ------------------------------------------------------------------------- WS
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@app.websocket("/v1/ws/{session_id}")
|
|
330
|
+
async def ws_session(ws: WebSocket, session_id: str) -> None:
|
|
331
|
+
await WS.connect(session_id, ws)
|
|
332
|
+
try:
|
|
333
|
+
await ws.send_json(
|
|
334
|
+
{
|
|
335
|
+
"type": "snapshot",
|
|
336
|
+
"graph": KERNEL.graph(session_id),
|
|
337
|
+
"telemetry": _telemetry_json(session_id),
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
while True:
|
|
341
|
+
await ws.receive_text() # keepalive / client pings
|
|
342
|
+
except (WebSocketDisconnect, KeyError):
|
|
343
|
+
pass
|
|
344
|
+
finally:
|
|
345
|
+
WS.drop(session_id, ws)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ------------------------------------------- Door 1: TokenFirewall proxy
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _msg_text(message: dict) -> str:
|
|
352
|
+
content = message.get("content", "")
|
|
353
|
+
if isinstance(content, str):
|
|
354
|
+
return content
|
|
355
|
+
return "\n".join(
|
|
356
|
+
block.get("text", "") for block in content if isinstance(block, dict)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _extract_step(text: str) -> tuple[str, object] | None:
|
|
361
|
+
"""Latest verified step in an assistant turn: ("patch", ops) folds AST-level
|
|
362
|
+
ops into canonical state; ("source", code) stages a whole module."""
|
|
363
|
+
patches = _PATCH_RE.findall(text)
|
|
364
|
+
if patches:
|
|
365
|
+
try:
|
|
366
|
+
return ("patch", json.loads(patches[-1]))
|
|
367
|
+
except json.JSONDecodeError:
|
|
368
|
+
return None
|
|
369
|
+
matches = _CODE_RE.findall(text)
|
|
370
|
+
return ("source", matches[-1]) if matches else None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def _run_firewall(
|
|
374
|
+
body: dict,
|
|
375
|
+
session_id: str,
|
|
376
|
+
node_id: str | None,
|
|
377
|
+
api: str,
|
|
378
|
+
focus: list[str] | None = None,
|
|
379
|
+
) -> dict:
|
|
380
|
+
messages: list[dict] = body.get("messages", [])
|
|
381
|
+
systems = [
|
|
382
|
+
_msg_text(m) for m in messages if m.get("role") == "system"
|
|
383
|
+
]
|
|
384
|
+
if api == "anthropic" and isinstance(body.get("system"), str):
|
|
385
|
+
systems.insert(0, body["system"])
|
|
386
|
+
|
|
387
|
+
# The latest assistant ```python block is the step output; everything else
|
|
388
|
+
# by the agent is dead-end scratch to park.
|
|
389
|
+
last_step, last_idx = None, -1
|
|
390
|
+
for i, m in enumerate(messages):
|
|
391
|
+
if m.get("role") == "assistant":
|
|
392
|
+
step = _extract_step(_msg_text(m))
|
|
393
|
+
if step:
|
|
394
|
+
last_step, last_idx = step, i
|
|
395
|
+
|
|
396
|
+
commit_info = None
|
|
397
|
+
for i, m in enumerate(messages):
|
|
398
|
+
if m.get("role") not in ("assistant", "tool"):
|
|
399
|
+
continue
|
|
400
|
+
if i == last_idx and last_step is not None and node_id:
|
|
401
|
+
kind, value = last_step
|
|
402
|
+
if kind == "patch":
|
|
403
|
+
verdict = KERNEL.commit(session_id, node_id, patch=value)
|
|
404
|
+
else:
|
|
405
|
+
verdict = KERNEL.commit(session_id, node_id, value)
|
|
406
|
+
commit_info = _verdict_json(verdict)
|
|
407
|
+
await _broadcast_mutation(
|
|
408
|
+
session_id, gate_event={"node_id": node_id, **commit_info}
|
|
409
|
+
)
|
|
410
|
+
else:
|
|
411
|
+
KERNEL.record_draft(session_id, node_id or "__chat__", _msg_text(m))
|
|
412
|
+
|
|
413
|
+
sent: list[dict] = [{"role": "system", "content": s} for s in systems]
|
|
414
|
+
if node_id:
|
|
415
|
+
payload = _or_404(KERNEL.checkout, session_id, node_id, focus=focus)
|
|
416
|
+
sent.append(
|
|
417
|
+
{
|
|
418
|
+
"role": "system",
|
|
419
|
+
"content": "## StepCheck CheckoutPayload\n\n" + payload.rendered(),
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
final_user = next(
|
|
423
|
+
(m for m in reversed(messages) if m.get("role") == "user"), None
|
|
424
|
+
)
|
|
425
|
+
if final_user is not None:
|
|
426
|
+
sent.append({"role": "user", "content": _msg_text(final_user)})
|
|
427
|
+
|
|
428
|
+
raw_tokens = sum(_count(_msg_text(m)) for m in messages) + sum(
|
|
429
|
+
_count(s) for s in systems if api == "anthropic"
|
|
430
|
+
)
|
|
431
|
+
sent_tokens = sum(_count(m["content"]) for m in sent)
|
|
432
|
+
return {
|
|
433
|
+
"session_id": session_id,
|
|
434
|
+
"node_id": node_id,
|
|
435
|
+
"raw_tokens": raw_tokens,
|
|
436
|
+
"sent_tokens": sent_tokens,
|
|
437
|
+
"tokens_pruned": raw_tokens - sent_tokens,
|
|
438
|
+
"sent_messages": sent,
|
|
439
|
+
"commit": commit_info,
|
|
440
|
+
"telemetry": _telemetry_json(session_id),
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _session_from(request: Request, body: dict) -> tuple[str, str | None, list[str] | None]:
|
|
445
|
+
sc = body.get("stepcheck", {}) if isinstance(body.get("stepcheck"), dict) else {}
|
|
446
|
+
session_id = request.headers.get("x-stepcheck-session") or sc.get("session_id")
|
|
447
|
+
node_id = request.headers.get("x-stepcheck-node") or sc.get("node_id")
|
|
448
|
+
# focus: header "a,b" or "" (pure skeleton); body.stepcheck.focus as a list.
|
|
449
|
+
focus: list[str] | None = None
|
|
450
|
+
if "x-stepcheck-focus" in request.headers:
|
|
451
|
+
raw = request.headers["x-stepcheck-focus"].strip()
|
|
452
|
+
focus = [f.strip() for f in raw.split(",") if f.strip()] if raw else []
|
|
453
|
+
elif isinstance(sc.get("focus"), list):
|
|
454
|
+
focus = [str(f) for f in sc["focus"]]
|
|
455
|
+
if not session_id:
|
|
456
|
+
raise HTTPException(
|
|
457
|
+
status_code=400,
|
|
458
|
+
detail="x-stepcheck-session header (or body.stepcheck.session_id) required",
|
|
459
|
+
)
|
|
460
|
+
return session_id, node_id, focus
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
async def _maybe_forward(
|
|
464
|
+
request: Request, body: dict, fw: dict, api: str
|
|
465
|
+
) -> dict | None:
|
|
466
|
+
"""BYOK upstream routing: only when the caller supplies key AND base URL."""
|
|
467
|
+
upstream = request.headers.get("x-stepcheck-upstream") or os.environ.get(
|
|
468
|
+
"STEPCHECK_UPSTREAM_BASE"
|
|
469
|
+
)
|
|
470
|
+
key = request.headers.get("authorization") or request.headers.get("x-api-key")
|
|
471
|
+
if not upstream or not key:
|
|
472
|
+
return None
|
|
473
|
+
path = "/v1/messages" if api == "anthropic" else "/v1/chat/completions"
|
|
474
|
+
out_body = dict(body)
|
|
475
|
+
out_body["messages"] = [
|
|
476
|
+
m for m in fw["sent_messages"] if not (api == "anthropic" and m["role"] == "system")
|
|
477
|
+
]
|
|
478
|
+
headers = {"content-type": "application/json"}
|
|
479
|
+
if api == "anthropic":
|
|
480
|
+
out_body["system"] = "\n\n".join(
|
|
481
|
+
m["content"] for m in fw["sent_messages"] if m["role"] == "system"
|
|
482
|
+
)
|
|
483
|
+
headers["x-api-key"] = request.headers.get("x-api-key", "")
|
|
484
|
+
headers["anthropic-version"] = request.headers.get(
|
|
485
|
+
"anthropic-version", "2023-06-01"
|
|
486
|
+
)
|
|
487
|
+
else:
|
|
488
|
+
headers["authorization"] = request.headers.get("authorization", "")
|
|
489
|
+
out_body.pop("stepcheck", None)
|
|
490
|
+
async with httpx.AsyncClient(timeout=120) as client:
|
|
491
|
+
resp = await client.post(upstream.rstrip("/") + path, json=out_body, headers=headers)
|
|
492
|
+
data = resp.json()
|
|
493
|
+
data["stepcheck"] = fw
|
|
494
|
+
return data
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
_DRY_NOTICE = (
|
|
498
|
+
"StepCheck dry-run: no BYOK upstream configured. The compacted request this "
|
|
499
|
+
"firewall would have forwarded is in `stepcheck.sent_messages`."
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@app.post("/v1/chat/completions")
|
|
504
|
+
async def chat_completions(request: Request) -> dict:
|
|
505
|
+
body = await request.json()
|
|
506
|
+
session_id, node_id, focus = _session_from(request, body)
|
|
507
|
+
fw = await _run_firewall(body, session_id, node_id, api="openai", focus=focus)
|
|
508
|
+
forwarded = await _maybe_forward(request, body, fw, api="openai")
|
|
509
|
+
if forwarded is not None:
|
|
510
|
+
return forwarded
|
|
511
|
+
return {
|
|
512
|
+
"id": f"sc-{uuid.uuid4().hex[:12]}",
|
|
513
|
+
"object": "chat.completion",
|
|
514
|
+
"created": 0,
|
|
515
|
+
"model": "stepcheck-dry-run",
|
|
516
|
+
"choices": [
|
|
517
|
+
{
|
|
518
|
+
"index": 0,
|
|
519
|
+
"message": {"role": "assistant", "content": _DRY_NOTICE},
|
|
520
|
+
"finish_reason": "stop",
|
|
521
|
+
}
|
|
522
|
+
],
|
|
523
|
+
"usage": {
|
|
524
|
+
"prompt_tokens": fw["sent_tokens"],
|
|
525
|
+
"completion_tokens": 0,
|
|
526
|
+
"total_tokens": fw["sent_tokens"],
|
|
527
|
+
},
|
|
528
|
+
"stepcheck": fw,
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@app.post("/v1/messages")
|
|
533
|
+
async def anthropic_messages(request: Request) -> dict:
|
|
534
|
+
body = await request.json()
|
|
535
|
+
session_id, node_id, focus = _session_from(request, body)
|
|
536
|
+
fw = await _run_firewall(body, session_id, node_id, api="anthropic", focus=focus)
|
|
537
|
+
forwarded = await _maybe_forward(request, body, fw, api="anthropic")
|
|
538
|
+
if forwarded is not None:
|
|
539
|
+
return forwarded
|
|
540
|
+
return {
|
|
541
|
+
"id": f"msg-sc-{uuid.uuid4().hex[:12]}",
|
|
542
|
+
"type": "message",
|
|
543
|
+
"role": "assistant",
|
|
544
|
+
"model": "stepcheck-dry-run",
|
|
545
|
+
"content": [{"type": "text", "text": _DRY_NOTICE}],
|
|
546
|
+
"stop_reason": "end_turn",
|
|
547
|
+
"usage": {"input_tokens": fw["sent_tokens"], "output_tokens": 0},
|
|
548
|
+
"stepcheck": fw,
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# ----------------------------------------------------- Studio static mount
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@app.get("/studio", include_in_schema=False)
|
|
556
|
+
async def studio_root() -> RedirectResponse:
|
|
557
|
+
return RedirectResponse(url="/studio/", status_code=307)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class _SpaStaticFiles(StaticFiles):
|
|
561
|
+
"""Serve real files; fall back to index.html for SPA deep links, but keep a
|
|
562
|
+
genuine 404 for missing hashed assets so broken builds surface."""
|
|
563
|
+
|
|
564
|
+
async def get_response(self, path: str, scope):
|
|
565
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
566
|
+
|
|
567
|
+
# Starlette builds `path` with os.path.join, so Windows yields backslashes.
|
|
568
|
+
path = path.replace("\\", "/")
|
|
569
|
+
try:
|
|
570
|
+
return await super().get_response(path, scope)
|
|
571
|
+
except StarletteHTTPException as exc:
|
|
572
|
+
if exc.status_code == 404 and not path.startswith("assets/"):
|
|
573
|
+
return await super().get_response("index.html", scope)
|
|
574
|
+
raise
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
if STUDIO_DIST.exists():
|
|
578
|
+
app.mount("/studio", _SpaStaticFiles(directory=str(STUDIO_DIST), html=True), name="studio")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
if __name__ == "__main__":
|
|
582
|
+
import uvicorn
|
|
583
|
+
|
|
584
|
+
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", "8420")))
|