windhover 0.10.0__tar.gz → 0.11.0__tar.gz
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.
- {windhover-0.10.0 → windhover-0.11.0}/PKG-INFO +7 -3
- {windhover-0.10.0 → windhover-0.11.0}/README.md +6 -2
- {windhover-0.10.0 → windhover-0.11.0}/pyproject.toml +1 -1
- {windhover-0.10.0 → windhover-0.11.0}/tests/test_smoke.py +65 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/extract.py +18 -3
- {windhover-0.10.0 → windhover-0.11.0}/windhover/server.py +7 -1
- {windhover-0.10.0 → windhover-0.11.0}/windhover/static/index.html +19 -4
- {windhover-0.10.0 → windhover-0.11.0}/windhover/store.py +7 -6
- {windhover-0.10.0 → windhover-0.11.0}/windhover/tracer.py +51 -7
- {windhover-0.10.0 → windhover-0.11.0}/.github/workflows/ci.yml +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/.gitignore +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/LICENSE +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/SPEC.md +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/docs/graph.png +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/docs/logo.svg +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/docs/runs.png +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/docs/social-preview.png +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/docs/stats.png +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/docs/trace.png +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/tests/__init__.py +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/__init__.py +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/config.py +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/demo_graph.py +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/pricing.json +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/static/icon.svg +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/static/manifest.json +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/static/vendor/cytoscape.min.js +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/static/vendor/dagre.min.js +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- {windhover-0.10.0 → windhover-0.11.0}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: windhover
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view.
|
|
5
5
|
Project-URL: Repository, https://github.com/justfeltlikerunning/windhover
|
|
6
6
|
Author: justfeltlikerunning
|
|
@@ -93,8 +93,12 @@ graph.invoke(input, config={
|
|
|
93
93
|
status/tag/session filters, bookmarks, pagination, CSV/JSON export.
|
|
94
94
|
- **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
|
|
95
95
|
- **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
|
|
96
|
-
- **Live tail** — open a running run and watch
|
|
97
|
-
|
|
96
|
+
- **Live tail** — open a running run and watch spans arrive — including **the model typing**
|
|
97
|
+
(streamed tokens flush into the span twice a second); nodes push progress via
|
|
98
|
+
`get_stream_writer()`.
|
|
99
|
+
- **Call configs** — every LLM span records temperature/max-tokens/stream **and the tools the
|
|
100
|
+
model was offered**; conditional-edge branch labels and `add_node(metadata=…)` render on the
|
|
101
|
+
graph and node pane; graphs with a context schema get a runtime-context box on New run.
|
|
98
102
|
- **Custom events** — `dispatch_custom_event("name", {...})` anywhere in your app lands as an
|
|
99
103
|
event marker in the trace, parented to the node that fired it.
|
|
100
104
|
- **Retries + TTFT** — tenacity retries badge the span (`↻2`); streaming LLM calls record
|
|
@@ -74,8 +74,12 @@ graph.invoke(input, config={
|
|
|
74
74
|
status/tag/session filters, bookmarks, pagination, CSV/JSON export.
|
|
75
75
|
- **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
|
|
76
76
|
- **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
|
|
77
|
-
- **Live tail** — open a running run and watch
|
|
78
|
-
|
|
77
|
+
- **Live tail** — open a running run and watch spans arrive — including **the model typing**
|
|
78
|
+
(streamed tokens flush into the span twice a second); nodes push progress via
|
|
79
|
+
`get_stream_writer()`.
|
|
80
|
+
- **Call configs** — every LLM span records temperature/max-tokens/stream **and the tools the
|
|
81
|
+
model was offered**; conditional-edge branch labels and `add_node(metadata=…)` render on the
|
|
82
|
+
graph and node pane; graphs with a context schema get a runtime-context box on New run.
|
|
79
83
|
- **Custom events** — `dispatch_custom_event("name", {...})` anywhere in your app lands as an
|
|
80
84
|
event marker in the trace, parented to the node that fired it.
|
|
81
85
|
- **Retries + TTFT** — tenacity retries badge the span (`↻2`); streaming LLM calls record
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "windhover"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.0"
|
|
8
8
|
description = "Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -423,6 +423,69 @@ def test_static_breakpoint_and_state_edit():
|
|
|
423
423
|
print("static breakpoint + state edit OK")
|
|
424
424
|
|
|
425
425
|
|
|
426
|
+
def test_streaming_partials_params_tools():
|
|
427
|
+
"""Token stream flushes partial spans (live 'model typing'); invocation
|
|
428
|
+
params + offered tools captured per LLM span."""
|
|
429
|
+
p = tempfile.mktemp(suffix=".db")
|
|
430
|
+
s = Store(p)
|
|
431
|
+
tr = SpanBuilder(db_sink(s), run_name="stream")
|
|
432
|
+
tr.on_chain_start({}, {"q": 1}, run_id="root", parent_run_id=None)
|
|
433
|
+
tr.on_chat_model_start(
|
|
434
|
+
{"kwargs": {"model": "fable-fast"}}, [[]], run_id="L", parent_run_id="root",
|
|
435
|
+
invocation_params={"model": "fable-fast", "temperature": 0.1, "stream": True,
|
|
436
|
+
"tools": [{"function": {"name": "get_weather"}}]})
|
|
437
|
+
tr.on_llm_new_token("Hello ", run_id="L")
|
|
438
|
+
tr._open["L"]["flushed"] = 0 # force the throttle window open
|
|
439
|
+
tr.on_llm_new_token("wind", run_id="L") # -> partial flush
|
|
440
|
+
mid = s.run_detail(tr.run_id)
|
|
441
|
+
llm_mid = [x for x in mid["spans"] if x["type"] == "llm"][0]
|
|
442
|
+
assert llm_mid["status"] == "running" and "Hello wind" in llm_mid["output"]
|
|
443
|
+
|
|
444
|
+
class Msg:
|
|
445
|
+
content = "Hello windhover"
|
|
446
|
+
usage_metadata = {"input_tokens": 10, "output_tokens": 3}
|
|
447
|
+
class Gen:
|
|
448
|
+
text = "Hello windhover"; message = Msg()
|
|
449
|
+
class Resp:
|
|
450
|
+
llm_output = None; generations = [[Gen()]]
|
|
451
|
+
tr.on_llm_end(Resp(), run_id="L")
|
|
452
|
+
tr.on_chain_end({}, run_id="root")
|
|
453
|
+
d = s.run_detail(tr.run_id)
|
|
454
|
+
llm = [x for x in d["spans"] if x["type"] == "llm"][0]
|
|
455
|
+
assert llm["status"] == "ok" and llm["output"] == "Hello windhover"
|
|
456
|
+
assert llm["params"]["temperature"] == 0.1
|
|
457
|
+
assert llm["params"]["tools_offered"] == ["get_weather"]
|
|
458
|
+
assert len([x for x in d["spans"] if x["type"] == "llm"]) == 1 # partial replaced
|
|
459
|
+
print("streaming partials + params/tools OK")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def test_edge_labels_and_node_metadata():
|
|
463
|
+
from typing import TypedDict, Literal
|
|
464
|
+
from langgraph.graph import StateGraph, START, END
|
|
465
|
+
from windhover.extract import topology
|
|
466
|
+
|
|
467
|
+
class St(TypedDict):
|
|
468
|
+
x: int
|
|
469
|
+
def route(st) -> Literal["yes", "no"]:
|
|
470
|
+
return "yes"
|
|
471
|
+
g = StateGraph(St)
|
|
472
|
+
g.add_node("decide", lambda st: {"x": st["x"]},
|
|
473
|
+
metadata={"owner": "team-a", "doc": "routing"})
|
|
474
|
+
g.add_node("approve", lambda st: {"x": 1})
|
|
475
|
+
g.add_node("reject", lambda st: {"x": 0})
|
|
476
|
+
g.add_edge(START, "decide")
|
|
477
|
+
g.add_conditional_edges("decide", route, {"yes": "approve", "no": "reject"})
|
|
478
|
+
g.add_edge("approve", END); g.add_edge("reject", END)
|
|
479
|
+
topo = topology(g.compile())
|
|
480
|
+
decide = next(n for n in topo["nodes"] if n["id"] == "decide")
|
|
481
|
+
assert decide["metadata"] == {"owner": "team-a", "doc": "routing"}
|
|
482
|
+
labels = {(e["source"], e["target"]): e["label"] for e in topo["edges"]}
|
|
483
|
+
assert labels[("decide", "approve")] == "yes"
|
|
484
|
+
assert labels[("decide", "reject")] == "no"
|
|
485
|
+
assert labels[("__start__", "decide")] is None
|
|
486
|
+
print("edge labels + node metadata OK")
|
|
487
|
+
|
|
488
|
+
|
|
426
489
|
if __name__ == "__main__":
|
|
427
490
|
test_cost(); print("cost OK")
|
|
428
491
|
test_store_roundtrip()
|
|
@@ -442,4 +505,6 @@ if __name__ == "__main__":
|
|
|
442
505
|
test_demo_memory_and_events_end_to_end()
|
|
443
506
|
test_hitl_interrupt_resume()
|
|
444
507
|
test_static_breakpoint_and_state_edit()
|
|
508
|
+
test_streaming_partials_params_tools()
|
|
509
|
+
test_edge_labels_and_node_metadata()
|
|
445
510
|
print("ALL SMOKE TESTS PASSED")
|
|
@@ -10,10 +10,12 @@ import sys, json, importlib, inspect, functools
|
|
|
10
10
|
def topology(graph, xray: bool = False) -> dict:
|
|
11
11
|
g = graph.get_graph(xray=True) if xray else graph.get_graph()
|
|
12
12
|
nodes = [{"id": nid, "label": str(getattr(n, "name", nid)).replace("__", ""),
|
|
13
|
-
"terminal": nid.endswith(("__start__", "__end__"))
|
|
13
|
+
"terminal": nid.endswith(("__start__", "__end__")),
|
|
14
|
+
"metadata": getattr(n, "metadata", None) or None}
|
|
14
15
|
for nid, n in g.nodes.items()]
|
|
15
16
|
edges = [{"id": f"e{i}", "source": e.source, "target": e.target,
|
|
16
|
-
"conditional": bool(getattr(e, "conditional", False))
|
|
17
|
+
"conditional": bool(getattr(e, "conditional", False)),
|
|
18
|
+
"label": str(e.data) if getattr(e, "data", None) not in (None, e.target) else None}
|
|
17
19
|
for i, e in enumerate(g.edges)]
|
|
18
20
|
return {"nodes": nodes, "edges": edges}
|
|
19
21
|
|
|
@@ -28,6 +30,18 @@ def input_schema(graph) -> dict:
|
|
|
28
30
|
return {}
|
|
29
31
|
|
|
30
32
|
|
|
33
|
+
def context_schema(graph) -> dict:
|
|
34
|
+
"""Runtime context/config schema (langgraph >=1.0 name, older fallback)."""
|
|
35
|
+
for meth in ("get_context_jsonschema", "get_config_jsonschema"):
|
|
36
|
+
try:
|
|
37
|
+
s = getattr(graph, meth)()
|
|
38
|
+
if isinstance(s, dict) and s.get("properties"):
|
|
39
|
+
return s
|
|
40
|
+
except Exception:
|
|
41
|
+
continue
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
|
|
31
45
|
def _unwrap(fn):
|
|
32
46
|
"""Peel partials, decorators, and bound methods down to the user function."""
|
|
33
47
|
seen = set()
|
|
@@ -93,7 +107,8 @@ def load(ref: str, dir_: str):
|
|
|
93
107
|
if __name__ == "__main__":
|
|
94
108
|
ref, dir_ = sys.argv[1], sys.argv[2]
|
|
95
109
|
g = load(ref, dir_)
|
|
96
|
-
out = {"topology": topology(g), "schema": input_schema(g), "sources": sources(g)
|
|
110
|
+
out = {"topology": topology(g), "schema": input_schema(g), "sources": sources(g),
|
|
111
|
+
"context_schema": context_schema(g)}
|
|
97
112
|
try: # subgraph x-ray view, only when it actually differs
|
|
98
113
|
tx = topology(g, xray=True)
|
|
99
114
|
if tx != out["topology"]:
|
|
@@ -156,7 +156,10 @@ def api_graph():
|
|
|
156
156
|
@app.get("/api/schema")
|
|
157
157
|
def api_schema():
|
|
158
158
|
s = TOPO.schema()
|
|
159
|
-
|
|
159
|
+
with TOPO.lock:
|
|
160
|
+
ctx = TOPO.data.get("context_schema") or {}
|
|
161
|
+
return JSONResponse({"schema": s, "template": _template(s),
|
|
162
|
+
"context_schema": ctx, "context_template": _template(ctx)})
|
|
160
163
|
|
|
161
164
|
|
|
162
165
|
def _sse(ev: str, data: dict) -> str:
|
|
@@ -229,12 +232,15 @@ async def api_run(request: Request):
|
|
|
229
232
|
thread = payload.pop("_thread", None)
|
|
230
233
|
pause_before = payload.pop("_interrupt_before", None)
|
|
231
234
|
pause_after = payload.pop("_interrupt_after", None)
|
|
235
|
+
extra_conf = payload.pop("_configurable", None)
|
|
232
236
|
tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref, session=session, tags=tags)
|
|
233
237
|
config = {"callbacks": [tracer]}
|
|
234
238
|
if thread or getattr(graph, "checkpointer", None) is not None:
|
|
235
239
|
# a checkpointed graph needs a thread; default to the run id so
|
|
236
240
|
# time-travel works out of the box
|
|
237
241
|
config["configurable"] = {"thread_id": thread or tracer.run_id}
|
|
242
|
+
if isinstance(extra_conf, dict) and extra_conf:
|
|
243
|
+
config.setdefault("configurable", {}).update(extra_conf)
|
|
238
244
|
sk = {}
|
|
239
245
|
if pause_before:
|
|
240
246
|
sk["interrupt_before"] = [str(n) for n in pause_before]
|
|
@@ -454,7 +454,7 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
454
454
|
<button data-v="memory" id="nav-mem" style="display:none">${ICON.mem} Memory</button>
|
|
455
455
|
<button data-v="stats">${ICON.chart} Stats</button>
|
|
456
456
|
</nav>
|
|
457
|
-
<div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.
|
|
457
|
+
<div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.11</span></div>
|
|
458
458
|
</aside>
|
|
459
459
|
|
|
460
460
|
<main>
|
|
@@ -554,6 +554,8 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
554
554
|
<input id="run-session" placeholder="session (optional)" style="flex:1;min-width:130px">
|
|
555
555
|
<input id="run-tags" placeholder="tags, comma-separated (optional)" style="flex:1;min-width:160px">
|
|
556
556
|
</div>
|
|
557
|
+
<div id="ctx-wrap" style="display:none"><div class="hint" style="margin:8px 0 4px">Runtime context / configurable (from the graph's context schema):</div>
|
|
558
|
+
<textarea id="ctx-json" spellcheck="false" style="min-height:70px"></textarea></div>
|
|
557
559
|
<div class="hint" style="margin:8px 0 4px">Pause before (static breakpoints — resume from the run drawer):</div>
|
|
558
560
|
<div id="pause-chips" style="display:flex;flex-wrap:wrap;gap:6px"></div>
|
|
559
561
|
<div class="actions">
|
|
@@ -613,6 +615,9 @@ function cyStyle(){ return [
|
|
|
613
615
|
{selector:'node.err',style:{'border-color':cssv('--err'),'background-color':cssv('--err-weak')}},
|
|
614
616
|
{selector:'edge',style:{'width':1.6,'line-color':cssv('--border2'),'target-arrow-color':cssv('--border2'),'target-arrow-shape':'triangle','curve-style':'bezier','arrow-scale':1}},
|
|
615
617
|
{selector:'edge[?conditional]',style:{'line-style':'dashed'}},
|
|
618
|
+
{selector:'edge[label]',style:{'label':'data(label)','font-size':10,
|
|
619
|
+
'font-family':'ui-monospace, Menlo, Consolas, monospace','color':cssv('--text2'),
|
|
620
|
+
'text-background-color':cssv('--bg'),'text-background-opacity':.9,'text-background-padding':2}},
|
|
616
621
|
{selector:'edge.flow',style:{'line-color':cssv('--accent'),'target-arrow-color':cssv('--accent'),'width':2.4}},
|
|
617
622
|
];}
|
|
618
623
|
function applyCyStyle(){ cy.style(cyStyle()); }
|
|
@@ -626,9 +631,10 @@ async function loadGraph(force){
|
|
|
626
631
|
const xb=$('#xray'); if(xb) xb.style.display=t.xray?'':'none';
|
|
627
632
|
if(!t.nodes.length){ $('#view-graph').innerHTML='<div class="empty" style="padding-top:120px"><div class="t">No local graph configured</div>Set <span class="mono">WINDHOVER_GRAPH</span>, or trace runs in from your app and open Runs.</div>'; return; }
|
|
628
633
|
const topo=(XRAY&&t.xray)?t.xray:t;
|
|
634
|
+
window.__nodes=Object.fromEntries(topo.nodes.map(n=>[n.id,n]));
|
|
629
635
|
const els=[];
|
|
630
636
|
topo.nodes.forEach(n=>els.push({data:{id:n.id,label:n.label,terminal:n.terminal?1:undefined}}));
|
|
631
|
-
topo.edges.forEach(e=>els.push({data:{id:e.id,source:e.source,target:e.target,conditional:e.conditional?1:undefined}}));
|
|
637
|
+
topo.edges.forEach(e=>els.push({data:{id:e.id,source:e.source,target:e.target,conditional:e.conditional?1:undefined,label:e.label||undefined}}));
|
|
632
638
|
if(cy&&!force){return;}
|
|
633
639
|
cy=cytoscape({container:$('#cy'),elements:els,style:cyStyle(),
|
|
634
640
|
layout:layoutOpts(),minZoom:.2,maxZoom:3,wheelSensitivity:.12});
|
|
@@ -704,6 +710,8 @@ async function openNode(name){
|
|
|
704
710
|
const hiAll=new Set();
|
|
705
711
|
if(src)(d.recent||[]).forEach(r=>{ if(r.error) tbLines(r.error,src).forEach(x=>hiAll.add(x)); });
|
|
706
712
|
let html=`<div class="kv"><div class="k">wiring</div><pre>${esc((inn.join(', ')||'START')+' → '+name+' → '+(out.join(', ')||'END'))}</pre></div>`;
|
|
713
|
+
const nmeta=(window.__nodes||{})[name]?.metadata;
|
|
714
|
+
if(nmeta) html+=kv('node metadata', nmeta);
|
|
707
715
|
if(src) html+=`<div class="kv"><div class="k">source · ${esc((src.file||'').split('/').pop())}:${src.line_start}${hiAll.size?` · <span style="color:var(--err)">error line${hiAll.size>1?'s':''} ${[...hiAll].join(', ')}</span>`:''}
|
|
708
716
|
<button class="copy" onclick="navigator.clipboard.writeText(__nodeSrc.code);toast('Copied source')">copy</button></div>${codeBlock(src,hiAll)}</div>`;
|
|
709
717
|
html+=`<div class="kv"><div class="k">executions — newest first, expand for payloads</div></div>`;
|
|
@@ -758,8 +766,12 @@ function switchView(v){ VIEW=v;
|
|
|
758
766
|
$$('.nav button,.bottom-nav button').forEach(b=>b.onclick=()=>switchView(b.dataset.v));
|
|
759
767
|
|
|
760
768
|
/* ---------- new run ---------- */
|
|
761
|
-
$('#newrun').onclick=async()=>{ let tpl={}; try{
|
|
769
|
+
$('#newrun').onclick=async()=>{ let tpl={},ctx={},cs={}; try{const sd=await(await fetch('/api/schema')).json(); tpl=sd.template||{}; ctx=sd.context_template||{}; cs=sd.context_schema||{};}catch(e){}
|
|
762
770
|
$('#input-json').value=JSON.stringify(tpl,null,2);
|
|
771
|
+
const cw=document.getElementById('ctx-wrap');
|
|
772
|
+
if(cw){ const has=cs&&cs.properties&&Object.keys(cs.properties).length;
|
|
773
|
+
cw.style.display=has?'':'none';
|
|
774
|
+
if(has) document.getElementById('ctx-json').value=JSON.stringify(ctx,null,2); }
|
|
763
775
|
const pc=document.getElementById('pause-chips');
|
|
764
776
|
if(pc&&cy) pc.innerHTML=cy.nodes().filter(n=>!n.data('terminal')).map(n=>
|
|
765
777
|
`<span class="tagchip pause-chip" data-n="${esc(n.id())}" onclick="this.classList.toggle('on')">${esc(n.id())}</span>`).join('');
|
|
@@ -770,6 +782,9 @@ $('#do-run').onclick=async()=>{ let input; try{input=JSON.parse($('#input-json')
|
|
|
770
782
|
if(sess)input._session=sess; if(tags.length)input._tags=tags;
|
|
771
783
|
const paused=[...document.querySelectorAll('.pause-chip.on')].map(e=>e.dataset.n);
|
|
772
784
|
if(paused.length) input._interrupt_before=paused;
|
|
785
|
+
const cw=document.getElementById('ctx-wrap');
|
|
786
|
+
if(cw&&cw.style.display!=='none'){ try{ const c=JSON.parse(document.getElementById('ctx-json').value||'{}');
|
|
787
|
+
if(Object.keys(c).length) input._configurable=c; }catch(e){ return toast('Invalid context JSON'); } }
|
|
773
788
|
closeAll(); switchView('graph'); await runNow(input); };
|
|
774
789
|
|
|
775
790
|
async function runNow(input){
|
|
@@ -956,7 +971,7 @@ function renderSpans(list,byParent,total,depth){
|
|
|
956
971
|
<div class="tr-meta">${meta}</div>
|
|
957
972
|
</div>
|
|
958
973
|
<div class="tr-detail" id="${did}">
|
|
959
|
-
${s.model?`<div class="kv"><div class="k">model</div><pre>${esc(s.model)}${s.prompt_tokens!=null?` · ${s.prompt_tokens} in / ${s.completion_tokens||0} out`:''}${s.usage_detail?' · '+esc(Object.entries(s.usage_detail).map(([k,v])=>`${v} ${k.replace('input_','').replace('output_','')}`).join(', ')):''}</pre></div>`:''}
|
|
974
|
+
${s.model?`<div class="kv"><div class="k">model</div><pre>${esc(s.model)}${s.prompt_tokens!=null?` · ${s.prompt_tokens} in / ${s.completion_tokens||0} out`:''}${s.usage_detail?' · '+esc(Object.entries(s.usage_detail).map(([k,v])=>`${v} ${k.replace('input_','').replace('output_','')}`).join(', ')):''}${s.params?'\n'+esc(Object.entries(s.params).map(([k,v])=>k==='tools_offered'?'tools: '+v.join(', '):k+'='+v).join(' · ')):''}</pre></div>`:''}
|
|
960
975
|
${s.error?`<div class="kv"><div class="k">error${s.type==='node'?`<button class="copy" onclick="showSpanSource('${s.id}')">view source</button>`:''}</div><pre class="err">${esc(s.error)}</pre><div id="src-${s.id}"></div></div>`:''}
|
|
961
976
|
${s.input!=null?kv('input',s.input):''}
|
|
962
977
|
${s.output!=null?kv('output',s.output):''}
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import json, os, sqlite3, threading, time, uuid
|
|
14
14
|
from typing import Any, Optional
|
|
15
15
|
|
|
16
|
-
SCHEMA_VERSION =
|
|
16
|
+
SCHEMA_VERSION = 7
|
|
17
17
|
_lock = threading.Lock()
|
|
18
18
|
|
|
19
19
|
|
|
@@ -70,7 +70,7 @@ class Store:
|
|
|
70
70
|
c.execute("ALTER TABLE runs ADD COLUMN thread_id TEXT")
|
|
71
71
|
scols = [r[1] for r in c.execute("PRAGMA table_info(spans)").fetchall()]
|
|
72
72
|
for col, typ in (("retries", "INTEGER"), ("ttft_ms", "INTEGER"),
|
|
73
|
-
("usage_detail", "TEXT")):
|
|
73
|
+
("usage_detail", "TEXT"), ("params", "TEXT")):
|
|
74
74
|
if col not in scols:
|
|
75
75
|
c.execute(f"ALTER TABLE spans ADD COLUMN {col} {typ}")
|
|
76
76
|
try:
|
|
@@ -139,8 +139,8 @@ class Store:
|
|
|
139
139
|
c.execute("""INSERT OR REPLACE INTO spans
|
|
140
140
|
(id,run_id,parent_id,seq,type,name,status,started_ms,ended_ms,offset_ms,
|
|
141
141
|
dur_ms,input,output,model,prompt_tokens,completion_tokens,cost_usd,error,
|
|
142
|
-
retries,ttft_ms,usage_detail)
|
|
143
|
-
VALUES(
|
|
142
|
+
retries,ttft_ms,usage_detail,params)
|
|
143
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
144
144
|
(s["id"], s["run_id"], s.get("parent_id"), s.get("seq", 0), s["type"],
|
|
145
145
|
s.get("name"), s.get("status", "ok"), s.get("started_ms"), s.get("ended_ms"),
|
|
146
146
|
s.get("offset_ms"), s.get("dur_ms"),
|
|
@@ -149,7 +149,8 @@ class Store:
|
|
|
149
149
|
s.get("model"), s.get("prompt_tokens"), s.get("completion_tokens"),
|
|
150
150
|
s.get("cost_usd"), s.get("error"),
|
|
151
151
|
s.get("retries"), s.get("ttft_ms"),
|
|
152
|
-
json.dumps(s.get("usage_detail")) if s.get("usage_detail") else None
|
|
152
|
+
json.dumps(s.get("usage_detail")) if s.get("usage_detail") else None,
|
|
153
|
+
json.dumps(s.get("params")) if s.get("params") else None))
|
|
153
154
|
if self.has_fts:
|
|
154
155
|
c.execute("DELETE FROM span_fts WHERE span_id=?", (s["id"],))
|
|
155
156
|
c.execute("INSERT INTO span_fts(text,span_id,run_id) VALUES(?,?,?)",
|
|
@@ -324,7 +325,7 @@ class Store:
|
|
|
324
325
|
for k in ("input", "tags"):
|
|
325
326
|
d[k] = json.loads(d[k]) if d.get(k) else None
|
|
326
327
|
for s in spans:
|
|
327
|
-
for k in ("input", "output", "usage_detail"):
|
|
328
|
+
for k in ("input", "output", "usage_detail", "params"):
|
|
328
329
|
s[k] = json.loads(s[k]) if s.get(k) else None
|
|
329
330
|
d["spans"] = spans
|
|
330
331
|
d["scores"] = scores
|
|
@@ -153,9 +153,13 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
153
153
|
model=None, pt=None, ct=None, usage_detail=None):
|
|
154
154
|
now = time.time()
|
|
155
155
|
sid = info["span_id"]
|
|
156
|
+
seq = info.get("seq")
|
|
157
|
+
if seq is None:
|
|
158
|
+
seq = self._seq
|
|
159
|
+
self._seq += 1
|
|
156
160
|
self._emit({"kind": "span", "id": sid, "run_id": self.run_id,
|
|
157
161
|
"parent_id": self._span_of.get(info.get("parent")),
|
|
158
|
-
"seq":
|
|
162
|
+
"seq": seq, "type": info["type"], "name": info["name"],
|
|
159
163
|
"status": status, "started_ms": int(info["t"] * 1000),
|
|
160
164
|
"ended_ms": int(now * 1000), "offset_ms": self._rel(info["t"]),
|
|
161
165
|
"dur_ms": int((now - info["t"]) * 1000),
|
|
@@ -164,8 +168,7 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
164
168
|
"cost_usd": cost_of(model, pt, ct), "error": error,
|
|
165
169
|
"retries": info.get("retries"),
|
|
166
170
|
"ttft_ms": int(info["ttft"] * 1000) if info.get("ttft") is not None else None,
|
|
167
|
-
"usage_detail": usage_detail})
|
|
168
|
-
self._seq += 1
|
|
171
|
+
"usage_detail": usage_detail, "params": info.get("params")})
|
|
169
172
|
|
|
170
173
|
# -- chains / nodes --
|
|
171
174
|
_INTERNAL_TAG_PREFIXES = ("graph:", "langsmith:", "seq:", "langgraph_")
|
|
@@ -180,6 +183,8 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
180
183
|
# config={"metadata": {"windhover_session": ..., "windhover_tags": [...]},
|
|
181
184
|
# "tags": [...]}
|
|
182
185
|
md = metadata or {}
|
|
186
|
+
if md.get("windhover_run_name"):
|
|
187
|
+
self.run_name = str(md["windhover_run_name"])
|
|
183
188
|
if md.get("windhover_session"):
|
|
184
189
|
self.session = str(md["windhover_session"])
|
|
185
190
|
user_tags = [t for t in (tags or [])
|
|
@@ -257,12 +262,31 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
257
262
|
"ended_ms": int(time.time() * 1000), "error": err})
|
|
258
263
|
|
|
259
264
|
# -- LLMs --
|
|
265
|
+
@staticmethod
|
|
266
|
+
def _llm_params(kw) -> Optional[dict]:
|
|
267
|
+
inv = kw.get("invocation_params") or {}
|
|
268
|
+
out = {}
|
|
269
|
+
for k in ("temperature", "max_tokens", "max_completion_tokens", "stream", "top_p"):
|
|
270
|
+
if inv.get(k) is not None:
|
|
271
|
+
out[k] = inv[k]
|
|
272
|
+
tools = []
|
|
273
|
+
for tl in (inv.get("tools") or []):
|
|
274
|
+
fn = tl.get("function", tl) if isinstance(tl, dict) else {}
|
|
275
|
+
if isinstance(fn, dict) and fn.get("name"):
|
|
276
|
+
tools.append(fn["name"])
|
|
277
|
+
if tools:
|
|
278
|
+
out["tools_offered"] = tools
|
|
279
|
+
return out or None
|
|
280
|
+
|
|
260
281
|
def _llm_start(self, serialized, prompt, run_id, parent_run_id, kw):
|
|
261
282
|
sid = uuid.uuid4().hex[:12]
|
|
262
283
|
self._span_of[run_id] = sid
|
|
284
|
+
seq = self._seq
|
|
285
|
+
self._seq += 1 # reserved now so streaming partials keep a stable order
|
|
263
286
|
self._open[run_id] = {"span_id": sid, "type": "llm",
|
|
264
287
|
"name": _model_name(serialized, kw), "t": time.time(),
|
|
265
|
-
"parent": parent_run_id, "input": _trunc(prompt)
|
|
288
|
+
"parent": parent_run_id, "input": _trunc(prompt),
|
|
289
|
+
"seq": seq, "params": self._llm_params(kw)}
|
|
266
290
|
|
|
267
291
|
def on_llm_start(self, serialized, prompts, *, run_id=None, parent_run_id=None, **kw):
|
|
268
292
|
self._llm_start(serialized, prompts, run_id, parent_run_id, kw)
|
|
@@ -285,10 +309,30 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
285
309
|
self._finish_span(info, status="error", error=_err_text(error), model=info["name"])
|
|
286
310
|
|
|
287
311
|
def on_llm_new_token(self, token, *, run_id=None, **kw):
|
|
288
|
-
# streaming: first token stamps time-to-first-token
|
|
312
|
+
# streaming: first token stamps time-to-first-token; the growing text is
|
|
313
|
+
# flushed as a partial span every ~0.5s so a live-tailed drawer shows
|
|
314
|
+
# the model typing
|
|
289
315
|
info = self._open.get(run_id)
|
|
290
|
-
if info is
|
|
291
|
-
|
|
316
|
+
if info is None:
|
|
317
|
+
return
|
|
318
|
+
now = time.time()
|
|
319
|
+
if "ttft" not in info:
|
|
320
|
+
info["ttft"] = now - info["t"]
|
|
321
|
+
info["buf"] = (info.get("buf") or "") + (token or "")
|
|
322
|
+
if now - info.get("flushed", 0) >= 0.5 and info.get("buf"):
|
|
323
|
+
info["flushed"] = now
|
|
324
|
+
self._emit({"kind": "span", "id": info["span_id"], "run_id": self.run_id,
|
|
325
|
+
"parent_id": self._span_of.get(info.get("parent")),
|
|
326
|
+
"seq": info["seq"], "type": "llm", "name": info["name"],
|
|
327
|
+
"status": "running", "started_ms": int(info["t"] * 1000),
|
|
328
|
+
"ended_ms": None, "offset_ms": self._rel(info["t"]),
|
|
329
|
+
"dur_ms": int((now - info["t"]) * 1000),
|
|
330
|
+
"input": info.get("input"), "output": info["buf"][-4000:],
|
|
331
|
+
"model": info["name"], "prompt_tokens": None,
|
|
332
|
+
"completion_tokens": None, "cost_usd": None, "error": None,
|
|
333
|
+
"retries": info.get("retries"),
|
|
334
|
+
"ttft_ms": int(info["ttft"] * 1000),
|
|
335
|
+
"usage_detail": None, "params": info.get("params")})
|
|
292
336
|
|
|
293
337
|
# -- retries (tenacity, e.g. rate limits) --
|
|
294
338
|
def on_retry(self, retry_state, *, run_id=None, **kw):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{windhover-0.10.0 → windhover-0.11.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2
RENAMED
|
File without changes
|
|
File without changes
|