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 +14 -0
- opensmith/cli.py +99 -0
- opensmith/models.py +51 -0
- opensmith/patcher.py +263 -0
- opensmith/server.py +78 -0
- opensmith/storage.py +281 -0
- opensmith/tokens.py +61 -0
- opensmith/tracer.py +154 -0
- opensmith/ui/index.html +624 -0
- opensmith-0.1.0.dist-info/METADATA +159 -0
- opensmith-0.1.0.dist-info/RECORD +13 -0
- opensmith-0.1.0.dist-info/WHEEL +4 -0
- opensmith-0.1.0.dist-info/entry_points.txt +2 -0
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()
|