opensmith 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.
opensmith/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ from opensmith.tracer import TraceCallable, trace
2
+ from opensmith.patcher import autopatch, unpatch
3
+ from opensmith.storage import Storage
4
+ from opensmith.models import Trace, Step, Run
5
+
6
+ __all__ = [
7
+ "trace",
8
+ "autopatch",
9
+ "unpatch",
10
+ "Storage",
11
+ "Trace",
12
+ "Step",
13
+ "Run",
14
+ ]
opensmith/cli.py ADDED
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ import click
7
+ import uvicorn
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from opensmith.storage import Storage
12
+
13
+
14
+ console = Console()
15
+
16
+
17
+ @click.group(name="opensmith")
18
+ def cli() -> None:
19
+ """Local-first LLM pipeline tracer."""
20
+
21
+
22
+ @cli.command()
23
+ @click.option("--port", default=7823, show_default=True, type=int)
24
+ @click.option("--host", default="127.0.0.1", show_default=True)
25
+ def ui(port: int, host: str) -> None:
26
+ """Start the local dashboard."""
27
+ click.echo(f"opensmith UI running at http://{host}:{port}")
28
+ click.echo("Press Ctrl+C to stop")
29
+ uvicorn.run("opensmith.server:app", host=host, port=port)
30
+
31
+
32
+ @cli.command()
33
+ def clear() -> None:
34
+ """Clear all traces."""
35
+ if not click.confirm("Clear all traces?", default=False):
36
+ return
37
+
38
+ Storage().delete_all()
39
+ click.echo("Cleared all traces.")
40
+
41
+
42
+ @cli.command()
43
+ def stats() -> None:
44
+ """Show trace statistics."""
45
+ data = Storage().get_stats()
46
+
47
+ table = Table(title="opensmith stats")
48
+ table.add_column("Metric")
49
+ table.add_column("Value", justify="right")
50
+
51
+ table.add_row("Total traces", str(data["total_traces"]))
52
+ table.add_row("Total steps", str(data["total_steps"]))
53
+ table.add_row("Total tokens", str(data["total_tokens"]))
54
+ table.add_row("Total cost (USD)", f"${float(data['total_cost_usd']):.6f}")
55
+
56
+ console.print(table)
57
+
58
+
59
+ @cli.command()
60
+ @click.option("--limit", default=20, show_default=True, type=int)
61
+ def traces(limit: int) -> None:
62
+ """List recent traces."""
63
+ rows = Storage().get_traces(limit=limit)
64
+
65
+ table = Table(title="opensmith traces")
66
+ table.add_column("id")
67
+ table.add_column("name")
68
+ table.add_column("latency_ms", justify="right")
69
+ table.add_column("error")
70
+ table.add_column("created_at")
71
+
72
+ for row in rows:
73
+ table.add_row(
74
+ str(row.get("id", ""))[:8],
75
+ str(row.get("name") or ""),
76
+ _format_latency(row.get("latency_ms")),
77
+ "yes" if row.get("error") else "no",
78
+ _format_timestamp(row.get("created_at")),
79
+ )
80
+
81
+ console.print(table)
82
+
83
+
84
+ def _format_latency(value: Any) -> str:
85
+ if value is None:
86
+ return ""
87
+ try:
88
+ return f"{float(value):.2f}"
89
+ except (TypeError, ValueError):
90
+ return ""
91
+
92
+
93
+ def _format_timestamp(value: Any) -> str:
94
+ if value is None:
95
+ return ""
96
+ try:
97
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(float(value)))
98
+ except (TypeError, ValueError, OSError):
99
+ return ""
opensmith/models.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Literal
5
+ from uuid import uuid4
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ StepType = Literal["llm", "retrieval", "tool", "custom"]
11
+
12
+
13
+ class Step(BaseModel):
14
+ id: str = Field(default_factory=lambda: str(uuid4()))
15
+ trace_id: str | None = None
16
+ name: str
17
+ input: Any | None = None
18
+ output: Any | None = None
19
+ error: str | None = None
20
+ start_time: float | None = None
21
+ end_time: float | None = None
22
+ latency_ms: float | None = None
23
+ tokens_input: int | None = None
24
+ tokens_output: int | None = None
25
+ tokens_total: int | None = None
26
+ model: str | None = None
27
+ cost_usd: float | None = None
28
+ step_type: StepType | None = None
29
+ metadata: dict[str, Any] | None = None
30
+
31
+
32
+ class Trace(BaseModel):
33
+ id: str = Field(default_factory=lambda: str(uuid4()))
34
+ name: str
35
+ input: Any | None = None
36
+ output: Any | None = None
37
+ error: str | None = None
38
+ start_time: float | None = None
39
+ end_time: float | None = None
40
+ latency_ms: float | None = None
41
+ parent_id: str | None = None
42
+ run_id: str | None = None
43
+ metadata: dict[str, Any] | None = None
44
+ steps: list[Step] = Field(default_factory=list)
45
+
46
+
47
+ class Run(BaseModel):
48
+ id: str = Field(default_factory=lambda: str(uuid4()))
49
+ name: str | None = None
50
+ tags: list[str] = Field(default_factory=list)
51
+ created_at: float = Field(default_factory=lambda: time.time())
opensmith/patcher.py ADDED
@@ -0,0 +1,263 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import importlib
5
+ import time
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from opensmith.models import Step
10
+ from opensmith.storage import Storage
11
+ from opensmith.tracer import _current_trace_id
12
+
13
+
14
+ PATCH_TARGETS: dict[str, list[dict[str, Any]]] = {
15
+ "openai": [
16
+ {
17
+ "module": "openai",
18
+ "path": ["chat", "completions", "create"],
19
+ "name": "chat.completions.create",
20
+ "step_type": "llm",
21
+ },
22
+ {
23
+ "module": "openai",
24
+ "path": ["embeddings", "create"],
25
+ "name": "embeddings.create",
26
+ "step_type": "llm",
27
+ },
28
+ ],
29
+ "anthropic": [
30
+ {
31
+ "module": "anthropic",
32
+ "path": ["messages", "create"],
33
+ "name": "messages.create",
34
+ "step_type": "llm",
35
+ },
36
+ ],
37
+ "litellm": [
38
+ {
39
+ "module": "litellm",
40
+ "path": ["completion"],
41
+ "name": "completion",
42
+ "step_type": "llm",
43
+ },
44
+ {
45
+ "module": "litellm",
46
+ "path": ["embedding"],
47
+ "name": "embedding",
48
+ "step_type": "llm",
49
+ },
50
+ ],
51
+ "qdrant": [
52
+ {
53
+ "module": "qdrant_client",
54
+ "path": ["QdrantClient", "search"],
55
+ "name": "QdrantClient.search",
56
+ "step_type": "retrieval",
57
+ },
58
+ {
59
+ "module": "qdrant_client",
60
+ "path": ["QdrantClient", "upsert"],
61
+ "name": "QdrantClient.upsert",
62
+ "step_type": "retrieval",
63
+ },
64
+ ],
65
+ "chromadb": [
66
+ {
67
+ "module": "chromadb.api.models.Collection",
68
+ "path": ["Collection", "query"],
69
+ "name": "Collection.query",
70
+ "step_type": "retrieval",
71
+ },
72
+ {
73
+ "module": "chromadb.api.models.Collection",
74
+ "path": ["Collection", "add"],
75
+ "name": "Collection.add",
76
+ "step_type": "retrieval",
77
+ },
78
+ ],
79
+ "pinecone": [
80
+ {
81
+ "module": "pinecone",
82
+ "path": ["Index", "query"],
83
+ "name": "Index.query",
84
+ "step_type": "retrieval",
85
+ },
86
+ {
87
+ "module": "pinecone",
88
+ "path": ["Index", "upsert"],
89
+ "name": "Index.upsert",
90
+ "step_type": "retrieval",
91
+ },
92
+ ],
93
+ }
94
+
95
+ _patched: dict[tuple[str, tuple[str, ...]], tuple[Any, str, Callable[..., Any]]] = {}
96
+
97
+
98
+ def autopatch(
99
+ only: list[str] | None = None,
100
+ exclude: list[str] | None = None,
101
+ storage: Storage | None = None,
102
+ ) -> None:
103
+ selected = set(only) if only is not None else set(PATCH_TARGETS)
104
+ excluded = set(exclude or [])
105
+ active_storage = storage or Storage()
106
+
107
+ for client_name in selected:
108
+ if client_name in excluded:
109
+ continue
110
+
111
+ for target in PATCH_TARGETS.get(client_name, []):
112
+ _patch_target(client_name, target, active_storage)
113
+
114
+
115
+ def unpatch() -> None:
116
+ for owner, attr_name, original in _patched.values():
117
+ setattr(owner, attr_name, original)
118
+ _patched.clear()
119
+
120
+
121
+ def _patch_target(
122
+ client_name: str,
123
+ target: dict[str, Any],
124
+ storage: Storage,
125
+ ) -> None:
126
+ module_name = target["module"]
127
+ path = tuple(target["path"])
128
+ key = (module_name, path)
129
+
130
+ if key in _patched:
131
+ return
132
+
133
+ try:
134
+ module = importlib.import_module(module_name)
135
+ owner, attr_name, original = _resolve_target(module, path)
136
+ except Exception:
137
+ return
138
+
139
+ if not callable(original):
140
+ return
141
+
142
+ wrapped = _wrap_method(
143
+ original=original,
144
+ storage=storage,
145
+ step_name=f"{client_name}.{target['name']}",
146
+ step_type=target["step_type"],
147
+ )
148
+
149
+ try:
150
+ setattr(owner, attr_name, wrapped)
151
+ except Exception:
152
+ return
153
+
154
+ _patched[key] = (owner, attr_name, original)
155
+
156
+
157
+ def _resolve_target(module: Any, path: tuple[str, ...]) -> tuple[Any, str, Callable[..., Any]]:
158
+ owner = module
159
+ for part in path[:-1]:
160
+ owner = getattr(owner, part)
161
+
162
+ attr_name = path[-1]
163
+ original = getattr(owner, attr_name)
164
+ return owner, attr_name, original
165
+
166
+
167
+ def _wrap_method(
168
+ original: Callable[..., Any],
169
+ storage: Storage,
170
+ step_name: str,
171
+ step_type: str,
172
+ ) -> Callable[..., Any]:
173
+ @functools.wraps(original)
174
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
175
+ start_time = time.time()
176
+ step = Step(
177
+ trace_id=_current_trace_id(),
178
+ name=step_name,
179
+ input={
180
+ "args": [str(arg) for arg in args],
181
+ "kwargs": {key: str(value) for key, value in kwargs.items()},
182
+ },
183
+ start_time=start_time,
184
+ model=str(kwargs["model"]) if "model" in kwargs else None,
185
+ step_type=step_type, # type: ignore[arg-type]
186
+ )
187
+
188
+ try:
189
+ result = original(*args, **kwargs)
190
+ step.output = _serialize_output(result)
191
+ _apply_usage(step, result)
192
+ return result
193
+ except Exception as exc:
194
+ step.error = repr(exc)
195
+ raise
196
+ finally:
197
+ step.end_time = time.time()
198
+ step.latency_ms = (step.end_time - start_time) * 1000
199
+ storage.save_step(step)
200
+
201
+ return wrapper
202
+
203
+
204
+ def _serialize_output(value: Any) -> Any:
205
+ if isinstance(value, str | int | float | bool | list | tuple | dict) or value is None:
206
+ return value
207
+ if hasattr(value, "model_dump"):
208
+ try:
209
+ return value.model_dump()
210
+ except Exception:
211
+ pass
212
+ if hasattr(value, "dict"):
213
+ try:
214
+ return value.dict()
215
+ except Exception:
216
+ pass
217
+ return str(value)
218
+
219
+
220
+ def _apply_usage(step: Step, result: Any) -> None:
221
+ usage = _get_value(result, "usage")
222
+ if usage is None:
223
+ return
224
+
225
+ tokens_input = (
226
+ _get_value(usage, "prompt_tokens")
227
+ or _get_value(usage, "input_tokens")
228
+ or _get_value(usage, "tokens_input")
229
+ )
230
+ tokens_output = (
231
+ _get_value(usage, "completion_tokens")
232
+ or _get_value(usage, "output_tokens")
233
+ or _get_value(usage, "tokens_output")
234
+ )
235
+ tokens_total = _get_value(usage, "total_tokens") or _get_value(
236
+ usage,
237
+ "tokens_total",
238
+ )
239
+
240
+ step.tokens_input = _to_int(tokens_input)
241
+ step.tokens_output = _to_int(tokens_output)
242
+ step.tokens_total = _to_int(tokens_total)
243
+
244
+ if step.tokens_total is None:
245
+ input_count = step.tokens_input or 0
246
+ output_count = step.tokens_output or 0
247
+ total = input_count + output_count
248
+ step.tokens_total = total if total else None
249
+
250
+
251
+ def _get_value(value: Any, key: str) -> Any:
252
+ if isinstance(value, dict):
253
+ return value.get(key)
254
+ return getattr(value, key, None)
255
+
256
+
257
+ def _to_int(value: Any) -> int | None:
258
+ if value is None:
259
+ return None
260
+ try:
261
+ return int(value)
262
+ except (TypeError, ValueError):
263
+ return None
opensmith/server.py ADDED
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import HTMLResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+
11
+ from opensmith.storage import Storage
12
+
13
+
14
+ UI_DIR = Path(__file__).parent / "ui"
15
+ INDEX_HTML = UI_DIR / "index.html"
16
+
17
+ storage = Storage()
18
+
19
+ app = FastAPI(title="opensmith", docs_url=None, redoc_url=None)
20
+
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=[
24
+ "http://localhost:7823",
25
+ "http://127.0.0.1:7823",
26
+ ],
27
+ allow_credentials=False,
28
+ allow_methods=["GET", "DELETE"],
29
+ allow_headers=["*"],
30
+ )
31
+
32
+ if UI_DIR.exists():
33
+ app.mount("/static", StaticFiles(directory=UI_DIR), name="static")
34
+
35
+
36
+ @app.get("/", response_class=HTMLResponse)
37
+ def index() -> HTMLResponse:
38
+ if INDEX_HTML.exists():
39
+ return HTMLResponse(INDEX_HTML.read_text(encoding="utf-8"))
40
+
41
+ return HTMLResponse(
42
+ """
43
+ <!doctype html>
44
+ <html>
45
+ <head>
46
+ <title>opensmith</title>
47
+ </head>
48
+ <body>
49
+ <h1>opensmith UI loading...</h1>
50
+ </body>
51
+ </html>
52
+ """
53
+ )
54
+
55
+
56
+ @app.get("/api/traces")
57
+ def get_traces(limit: int = 50) -> list[dict[str, Any]]:
58
+ return storage.get_traces(limit=limit)
59
+
60
+
61
+ @app.get("/api/traces/{trace_id}")
62
+ def get_trace(trace_id: str) -> dict[str, Any]:
63
+ trace, steps = storage.get_trace(trace_id)
64
+ return {
65
+ "trace": trace,
66
+ "steps": steps,
67
+ }
68
+
69
+
70
+ @app.delete("/api/traces")
71
+ def delete_traces() -> dict[str, str]:
72
+ storage.delete_all()
73
+ return {"status": "cleared"}
74
+
75
+
76
+ @app.get("/api/stats")
77
+ def get_stats() -> dict[str, Any]:
78
+ return storage.get_stats()