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.
Files changed (32) hide show
  1. {windhover-0.10.0 → windhover-0.11.0}/PKG-INFO +7 -3
  2. {windhover-0.10.0 → windhover-0.11.0}/README.md +6 -2
  3. {windhover-0.10.0 → windhover-0.11.0}/pyproject.toml +1 -1
  4. {windhover-0.10.0 → windhover-0.11.0}/tests/test_smoke.py +65 -0
  5. {windhover-0.10.0 → windhover-0.11.0}/windhover/extract.py +18 -3
  6. {windhover-0.10.0 → windhover-0.11.0}/windhover/server.py +7 -1
  7. {windhover-0.10.0 → windhover-0.11.0}/windhover/static/index.html +19 -4
  8. {windhover-0.10.0 → windhover-0.11.0}/windhover/store.py +7 -6
  9. {windhover-0.10.0 → windhover-0.11.0}/windhover/tracer.py +51 -7
  10. {windhover-0.10.0 → windhover-0.11.0}/.github/workflows/ci.yml +0 -0
  11. {windhover-0.10.0 → windhover-0.11.0}/.gitignore +0 -0
  12. {windhover-0.10.0 → windhover-0.11.0}/LICENSE +0 -0
  13. {windhover-0.10.0 → windhover-0.11.0}/SPEC.md +0 -0
  14. {windhover-0.10.0 → windhover-0.11.0}/docs/graph.png +0 -0
  15. {windhover-0.10.0 → windhover-0.11.0}/docs/logo.svg +0 -0
  16. {windhover-0.10.0 → windhover-0.11.0}/docs/runs.png +0 -0
  17. {windhover-0.10.0 → windhover-0.11.0}/docs/social-preview.png +0 -0
  18. {windhover-0.10.0 → windhover-0.11.0}/docs/stats.png +0 -0
  19. {windhover-0.10.0 → windhover-0.11.0}/docs/trace.png +0 -0
  20. {windhover-0.10.0 → windhover-0.11.0}/tests/__init__.py +0 -0
  21. {windhover-0.10.0 → windhover-0.11.0}/windhover/__init__.py +0 -0
  22. {windhover-0.10.0 → windhover-0.11.0}/windhover/config.py +0 -0
  23. {windhover-0.10.0 → windhover-0.11.0}/windhover/demo_graph.py +0 -0
  24. {windhover-0.10.0 → windhover-0.11.0}/windhover/pricing.json +0 -0
  25. {windhover-0.10.0 → windhover-0.11.0}/windhover/static/icon.svg +0 -0
  26. {windhover-0.10.0 → windhover-0.11.0}/windhover/static/manifest.json +0 -0
  27. {windhover-0.10.0 → windhover-0.11.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  28. {windhover-0.10.0 → windhover-0.11.0}/windhover/static/vendor/cytoscape.min.js +0 -0
  29. {windhover-0.10.0 → windhover-0.11.0}/windhover/static/vendor/dagre.min.js +0 -0
  30. {windhover-0.10.0 → windhover-0.11.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  31. {windhover-0.10.0 → windhover-0.11.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  32. {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.10.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 its spans arrive in real time; nodes can push
97
- progress via LangGraph's `get_stream_writer()`.
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 its spans arrive in real time; nodes can push
78
- progress via LangGraph's `get_stream_writer()`.
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.10.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
- return JSONResponse({"schema": s, "template": _template(s)})
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.10</span></div>
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{tpl=(await(await fetch('/api/schema')).json()).template||{};}catch(e){}
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 = 6
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": self._seq, "type": info["type"], "name": info["name"],
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 not None and "ttft" not in info:
291
- info["ttft"] = time.time() - info["t"]
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