hedit 0.7.11.dev3__tar.gz → 0.7.11.dev6__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 (93) hide show
  1. {hedit-0.7.11.dev3/hedit.egg-info → hedit-0.7.11.dev6}/PKG-INFO +1 -1
  2. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/README.md +5 -0
  3. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6/hedit.egg-info}/PKG-INFO +1 -1
  4. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/hedit.egg-info/SOURCES.txt +3 -0
  5. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/pyproject.toml +1 -1
  6. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/api/main.py +200 -63
  7. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/api/models.py +67 -0
  8. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/cli/client.py +5 -5
  9. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/cli/config.py +3 -2
  10. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/cli/local_executor.py +83 -35
  11. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/cli/main.py +1 -1
  12. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/cli/output.py +83 -11
  13. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/scripts/process_feedback.py +1 -0
  14. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/telemetry/schema.py +59 -11
  15. hedit-0.7.11.dev6/src/utils/anthropic_llm.py +475 -0
  16. hedit-0.7.11.dev6/src/utils/llm_usage.py +345 -0
  17. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/validation/hed_validator.py +9 -2
  18. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/version.py +1 -1
  19. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_annotation_agent.py +55 -0
  20. hedit-0.7.11.dev6/tests/test_anthropic_llm.py +451 -0
  21. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_api_endpoints.py +153 -0
  22. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_cli_client.py +14 -2
  23. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_cli_integration.py +3 -3
  24. hedit-0.7.11.dev6/tests/test_cli_usage_report.py +209 -0
  25. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_integration_anthropic.py +102 -0
  26. hedit-0.7.11.dev6/tests/test_llm_usage.py +294 -0
  27. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_telemetry.py +99 -0
  28. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_validation.py +55 -6
  29. hedit-0.7.11.dev3/src/utils/anthropic_llm.py +0 -246
  30. hedit-0.7.11.dev3/tests/test_anthropic_llm.py +0 -170
  31. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/LICENSE +0 -0
  32. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/PKG_README.md +0 -0
  33. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/hedit.egg-info/dependency_links.txt +0 -0
  34. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/hedit.egg-info/entry_points.txt +0 -0
  35. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/hedit.egg-info/requires.txt +0 -0
  36. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/hedit.egg-info/top_level.txt +0 -0
  37. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/setup.cfg +0 -0
  38. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/__init__.py +0 -0
  39. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/agents/__init__.py +0 -0
  40. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/agents/annotation_agent.py +0 -0
  41. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/agents/assessment_agent.py +0 -0
  42. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/agents/evaluation_agent.py +0 -0
  43. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/agents/feedback_summarizer.py +0 -0
  44. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/agents/feedback_triage_agent.py +0 -0
  45. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/agents/state.py +0 -0
  46. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/agents/validation_agent.py +0 -0
  47. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/agents/vision_agent.py +0 -0
  48. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/agents/workflow.py +0 -0
  49. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/api/__init__.py +0 -0
  50. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/api/security.py +0 -0
  51. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/cli/__init__.py +0 -0
  52. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/cli/api_executor.py +0 -0
  53. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/cli/commands/__init__.py +0 -0
  54. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/cli/commands/lsp.py +0 -0
  55. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/cli/executor.py +0 -0
  56. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/data/__init__.py +0 -0
  57. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/data/hed-docs/02_Terminology.md +0 -0
  58. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/data/hed-docs/HedAnnotationSemantics.md +0 -0
  59. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/data/hed-docs/manifest.json +0 -0
  60. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/lsp/__init__.py +0 -0
  61. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/lsp/client.py +0 -0
  62. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/lsp/daemon.py +0 -0
  63. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/lsp/protocol.py +0 -0
  64. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/scripts/__init__.py +0 -0
  65. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/telemetry/__init__.py +0 -0
  66. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/telemetry/collector.py +0 -0
  67. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/telemetry/storage.py +0 -0
  68. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/utils/__init__.py +0 -0
  69. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/utils/error_remediation.py +0 -0
  70. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/utils/github_client.py +0 -0
  71. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/utils/hed_comprehensive_guide.py +0 -0
  72. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/utils/hed_docs_loader.py +0 -0
  73. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/utils/image_processing.py +0 -0
  74. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/utils/json_schema_loader.py +0 -0
  75. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/utils/schema_loader.py +0 -0
  76. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/validation/__init__.py +0 -0
  77. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/src/validation/hed_lsp.py +0 -0
  78. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_cli_config.py +0 -0
  79. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_cli_main.py +0 -0
  80. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_comprehensive_guide.py +0 -0
  81. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_error_remediation.py +0 -0
  82. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_feedback_integration.py +0 -0
  83. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_feedback_triage.py +0 -0
  84. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_fetch_hed_docs.py +0 -0
  85. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_github_client.py +0 -0
  86. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_hed_docs_loader.py +0 -0
  87. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_json_schema_loader.py +0 -0
  88. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_no_extend_propagation.py +0 -0
  89. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_schema_loader.py +0 -0
  90. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_security.py +0 -0
  91. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_state.py +0 -0
  92. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_validation_agent.py +0 -0
  93. {hedit-0.7.11.dev3 → hedit-0.7.11.dev6}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hedit
3
- Version: 0.7.11.dev3
3
+ Version: 0.7.11.dev6
4
4
  Summary: Multi-agent system for HED annotation generation and validation
5
5
  Author-email: Annotation Garden Initiative <info@annotation.garden>
6
6
  License-Expression: MIT
@@ -67,6 +67,7 @@ Config files are stored in `~/.config/hedit/`:
67
67
  - **Image Annotation**: Annotate visual stimuli directly from image files
68
68
  - **Multi-Stage Validation**: AI agents generate, validate, evaluate, and refine annotations
69
69
  - **Claude-Powered**: Anthropic Claude models (Haiku 4.5 default, Sonnet 5 optional); bring your own Anthropic key if you prefer your own billing
70
+ - **Cost Transparency**: Every annotation reports its token use and how much prompt caching saved (typically ~80% of input cost after the first request)
70
71
  - **JSON Output**: Easy integration with scripts and pipelines
71
72
  - **HED Schema Support**: Works with official HED schemas (8.x)
72
73
 
@@ -83,6 +84,9 @@ The agents work in feedback loops, automatically refining the annotation until i
83
84
 
84
85
  ## Documentation
85
86
 
87
+ - [Changelog](CHANGELOG.md) - What changed in each release
88
+ - [Prompt Caching and Usage Reporting](docs/prompt-caching.md) - What HEDit caches, what it saves, and where to see the numbers
89
+ - [Extended Thinking](docs/reasoning.md) - Measured effect of reasoning per agent role, and how to tune it
86
90
  - [HED Standard](https://hedtags.org) - Learn about HED annotations
87
91
  - [GitHub Issues](https://github.com/Annotation-Garden/HEDit/issues) - Report bugs or request features
88
92
 
@@ -234,6 +238,7 @@ uvicorn src.api.main:app --reload --host 0.0.0.0 --port 38427
234
238
  - `POST /annotate`: Generate HED annotation from natural language
235
239
  - `POST /validate`: Validate HED annotation
236
240
  - `GET /health`: Health check
241
+ - `GET /metrics`: Token use, cost, and prompt-cache savings since startup (server API key required)
237
242
  - API URL: `http://localhost:38427`
238
243
 
239
244
  ## Development
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hedit
3
- Version: 0.7.11.dev3
3
+ Version: 0.7.11.dev6
4
4
  Summary: Multi-agent system for HED annotation generation and validation
5
5
  Author-email: Annotation Garden Initiative <info@annotation.garden>
6
6
  License-Expression: MIT
@@ -56,6 +56,7 @@ src/utils/hed_comprehensive_guide.py
56
56
  src/utils/hed_docs_loader.py
57
57
  src/utils/image_processing.py
58
58
  src/utils/json_schema_loader.py
59
+ src/utils/llm_usage.py
59
60
  src/utils/schema_loader.py
60
61
  src/validation/__init__.py
61
62
  src/validation/hed_lsp.py
@@ -67,6 +68,7 @@ tests/test_cli_client.py
67
68
  tests/test_cli_config.py
68
69
  tests/test_cli_integration.py
69
70
  tests/test_cli_main.py
71
+ tests/test_cli_usage_report.py
70
72
  tests/test_comprehensive_guide.py
71
73
  tests/test_error_remediation.py
72
74
  tests/test_feedback_integration.py
@@ -76,6 +78,7 @@ tests/test_github_client.py
76
78
  tests/test_hed_docs_loader.py
77
79
  tests/test_integration_anthropic.py
78
80
  tests/test_json_schema_loader.py
81
+ tests/test_llm_usage.py
79
82
  tests/test_no_extend_propagation.py
80
83
  tests/test_schema_loader.py
81
84
  tests/test_security.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hedit"
7
- version = "0.7.11.dev3"
7
+ version = "0.7.11.dev6"
8
8
  description = "Multi-agent system for HED annotation generation and validation"
9
9
  readme = "PKG_README.md"
10
10
  requires-python = ">=3.12"
@@ -10,6 +10,7 @@ import logging
10
10
  import os
11
11
  import time
12
12
  from contextlib import asynccontextmanager
13
+ from datetime import UTC, datetime
13
14
  from pathlib import Path
14
15
 
15
16
  import anthropic
@@ -31,13 +32,21 @@ from src.api.models import (
31
32
  HealthResponse,
32
33
  ImageAnnotationRequest,
33
34
  ImageAnnotationResponse,
35
+ MetricsResponse,
36
+ UsageSummary,
34
37
  ValidationRequest,
35
38
  ValidationResponse,
36
39
  )
37
40
  from src.api.security import api_key_auth, audit_logger
38
41
  from src.lsp import HedLspClient
39
42
  from src.telemetry import LocalFileStorage, TelemetryCollector, TelemetryEvent
40
- from src.utils.anthropic_llm import DEFAULT_MODEL, create_anthropic_llm, normalize_model
43
+ from src.utils.anthropic_llm import (
44
+ DEFAULT_MODEL,
45
+ annotation_thinking,
46
+ create_anthropic_llm,
47
+ normalize_model,
48
+ )
49
+ from src.utils.llm_usage import UsageLedger, process_ledger, usage_scope
41
50
  from src.utils.schema_loader import HedSchemaLoader
42
51
  from src.validation.hed_validator import HedPythonValidator
43
52
 
@@ -56,6 +65,7 @@ lsp_client: HedLspClient | None = None
56
65
 
57
66
  # Telemetry collector (initialized in lifespan)
58
67
  telemetry_collector: TelemetryCollector | None = None
68
+ _startup_time: str = datetime.now(UTC).isoformat()
59
69
 
60
70
  # Cache for BYOK configuration
61
71
  _byok_config: dict = {}
@@ -100,6 +110,41 @@ def _describe_llm_error(exc: Exception) -> tuple[int, str, str]:
100
110
  _USE_GLOBAL_LSP: HedLspClient = object() # type: ignore[assignment]
101
111
 
102
112
 
113
+ def _usage_summary(ledger: UsageLedger) -> UsageSummary | None:
114
+ """Build the response's usage figures from a request's usage ledger.
115
+
116
+ Returns None when no LLM call was recorded (a fully cached or failed
117
+ request), so clients can tell "no calls" from "zero cost".
118
+
119
+ Args:
120
+ ledger: Ledger collected for one request
121
+
122
+ Returns:
123
+ UsageSummary, or None when nothing was recorded
124
+ """
125
+ totals = ledger.total()
126
+ if totals.calls == 0:
127
+ return None
128
+ return UsageSummary(**totals.as_dict())
129
+
130
+
131
+ def _override_header(req: Request, name: str) -> str | None:
132
+ """Read a per-request override header, preferring the X-Anthropic-* spelling.
133
+
134
+ The X-OpenRouter-* names are the wire spelling from before the Anthropic
135
+ migration. They remain accepted indefinitely so that cached frontends and
136
+ third-party clients keep working; current clients send X-Anthropic-*.
137
+
138
+ Args:
139
+ req: Incoming request
140
+ name: Header suffix, e.g. "model" for X-Anthropic-Model
141
+
142
+ Returns:
143
+ Header value, or None when neither spelling is present
144
+ """
145
+ return req.headers.get(f"x-anthropic-{name}") or req.headers.get(f"x-openrouter-{name}")
146
+
147
+
103
148
  def _resolve_lsp_client(
104
149
  explicit: HedLspClient | None,
105
150
  ) -> HedLspClient | None:
@@ -160,13 +205,16 @@ def create_anthropic_workflow(
160
205
  normalize_model(actual_eval_model)
161
206
 
162
207
  # Create LLMs.
163
- # The annotation LLM keeps reasoning enabled that's the model
164
- # doing the actual HED tag synthesis where extended thinking
165
- # measurably improves first-attempt quality.
208
+ # Annotation thinks: measured over the benchmark descriptions, a 2048-token
209
+ # budget took first-attempt validity from 5/15 to 13/15 and cut total LLM
210
+ # calls by a third, for 24% more cost and about twice the latency. See
211
+ # annotation_thinking() and docs/prompt-caching.md.
166
212
  annotation_llm = create_anthropic_llm(
167
213
  model=actual_annotation_model,
168
214
  api_key=api_key,
169
215
  temperature=actual_temperature,
216
+ thinking=annotation_thinking(actual_annotation_model),
217
+ role="annotation",
170
218
  )
171
219
  # Evaluation / assessment / feedback / keyword extraction are short
172
220
  # structured tasks; reasoning adds 5-10 s per call without
@@ -176,18 +224,21 @@ def create_anthropic_workflow(
176
224
  api_key=api_key,
177
225
  temperature=actual_temperature,
178
226
  disable_reasoning=True,
227
+ role="evaluation",
179
228
  )
180
229
  assessment_llm = create_anthropic_llm(
181
230
  model=actual_eval_model,
182
231
  api_key=api_key,
183
232
  temperature=actual_temperature,
184
233
  disable_reasoning=True,
234
+ role="assessment",
185
235
  )
186
236
  feedback_llm = create_anthropic_llm(
187
237
  model=actual_eval_model,
188
238
  api_key=api_key,
189
239
  temperature=actual_temperature,
190
240
  disable_reasoning=True,
241
+ role="feedback",
191
242
  )
192
243
  # Keyword extraction (#148): use the fast annotation model with
193
244
  # reasoning explicitly disabled and a small token cap. The
@@ -199,6 +250,7 @@ def create_anthropic_workflow(
199
250
  temperature=actual_temperature,
200
251
  max_tokens=200,
201
252
  disable_reasoning=True,
253
+ role="keyword",
202
254
  )
203
255
 
204
256
  # Create and return workflow
@@ -283,6 +335,7 @@ def create_vision_agent(
283
335
  model=actual_model,
284
336
  api_key=api_key,
285
337
  temperature=actual_temperature,
338
+ role="vision",
286
339
  )
287
340
 
288
341
  return VisionAgent(llm=vision_llm)
@@ -477,6 +530,9 @@ async def lifespan(app: FastAPI):
477
530
  exc_info=True,
478
531
  )
479
532
 
533
+ global _startup_time
534
+ _startup_time = datetime.now(UTC).isoformat()
535
+
480
536
  # Initialize telemetry collector
481
537
  global telemetry_collector
482
538
  # Use /app/telemetry in Docker, otherwise use local .hedit/telemetry
@@ -555,13 +611,18 @@ app.add_middleware(
555
611
  "X-Requested-With",
556
612
  "X-API-Key",
557
613
  "X-Anthropic-Key", # BYOK mode (Anthropic API key)
558
- "X-OpenRouter-Key", # Legacy BYOK header (still accepted as transport)
559
- "X-OpenRouter-Model", # Model override
560
- "X-OpenRouter-Vision-Model", # Vision model override
614
+ "X-Anthropic-Model", # Model override
615
+ "X-Anthropic-Eval-Model", # Eval model override
616
+ "X-Anthropic-Vision-Model", # Vision model override
617
+ "X-Anthropic-Temperature", # Temperature override
618
+ # Legacy X-OpenRouter-* spellings, still accepted as transport
619
+ "X-OpenRouter-Key",
620
+ "X-OpenRouter-Model",
621
+ "X-OpenRouter-Vision-Model",
561
622
  "X-OpenRouter-Vision-Provider", # Legacy, ignored
562
623
  "X-OpenRouter-Provider", # Legacy, ignored
563
- "X-OpenRouter-Temperature", # Temperature override
564
- "X-OpenRouter-Eval-Model", # Eval model override
624
+ "X-OpenRouter-Temperature",
625
+ "X-OpenRouter-Eval-Model",
565
626
  "X-OpenRouter-Eval-Provider", # Legacy, ignored
566
627
  "X-User-Id", # Legacy, ignored
567
628
  ],
@@ -646,9 +707,9 @@ async def annotate(
646
707
  """
647
708
  # Determine which workflow to use
648
709
  # Check for model override headers (from frontend dropdown or CLI)
649
- model_override = request.model or req.headers.get("x-openrouter-model")
650
- eval_model_override = req.headers.get("x-openrouter-eval-model")
651
- temp_header = req.headers.get("x-openrouter-temperature")
710
+ model_override = request.model or _override_header(req, "model")
711
+ eval_model_override = _override_header(req, "eval-model")
712
+ temp_header = _override_header(req, "temperature")
652
713
  temperature = request.temperature
653
714
  if temperature is None and temp_header:
654
715
  try:
@@ -658,7 +719,7 @@ async def annotate(
658
719
 
659
720
  if api_key == "byok":
660
721
  # BYOK mode: Create workflow with user's Anthropic key
661
- byok_key = req.headers.get("x-anthropic-key") or req.headers.get("x-openrouter-key")
722
+ byok_key = _override_header(req, "key")
662
723
  if not byok_key:
663
724
  raise HTTPException(status_code=401, detail="Missing X-Anthropic-Key header")
664
725
 
@@ -707,14 +768,15 @@ async def annotate(
707
768
  config = {"recursion_limit": 50}
708
769
 
709
770
  start_time = time.time()
710
- final_state = await active_workflow.run(
711
- input_description=request.description,
712
- schema_version=request.schema_version,
713
- max_validation_attempts=request.max_validation_attempts,
714
- run_assessment=request.run_assessment,
715
- no_extend=request.no_extend,
716
- config=config,
717
- )
771
+ with usage_scope() as usage:
772
+ final_state = await active_workflow.run(
773
+ input_description=request.description,
774
+ schema_version=request.schema_version,
775
+ max_validation_attempts=request.max_validation_attempts,
776
+ run_assessment=request.run_assessment,
777
+ no_extend=request.no_extend,
778
+ config=config,
779
+ )
718
780
  latency_ms = int((time.time() - start_time) * 1000)
719
781
 
720
782
  # Determine overall status
@@ -728,12 +790,12 @@ async def annotate(
728
790
  # Get model info from request body, BYOK headers, or server config
729
791
  model_name = (
730
792
  request.model
731
- or req.headers.get("x-openrouter-model")
793
+ or _override_header(req, "model")
732
794
  or os.getenv("ANNOTATION_MODEL", DEFAULT_MODEL)
733
795
  )
734
796
  temperature = request.temperature
735
797
  if temperature is None:
736
- temp_header = req.headers.get("x-openrouter-temperature")
798
+ temp_header = _override_header(req, "temperature")
737
799
  if temp_header is not None:
738
800
  try:
739
801
  temperature = float(temp_header)
@@ -753,6 +815,7 @@ async def annotate(
753
815
  temperature=temperature,
754
816
  latency_ms=latency_ms,
755
817
  source="api",
818
+ usage=usage.total(),
756
819
  )
757
820
  await telemetry_collector.collect(event)
758
821
 
@@ -767,6 +830,7 @@ async def annotate(
767
830
  evaluation_feedback=final_state["evaluation_feedback"],
768
831
  assessment_feedback=final_state["assessment_feedback"],
769
832
  status=status,
833
+ usage=_usage_summary(usage),
770
834
  )
771
835
 
772
836
  except Exception as e:
@@ -803,10 +867,10 @@ async def annotate_from_image(
803
867
  """
804
868
  # Determine which workflow and vision agent to use
805
869
  # Check for model override headers (from frontend dropdown or CLI)
806
- model_override = request.model or req.headers.get("x-openrouter-model")
807
- vision_model_override = request.vision_model or req.headers.get("x-openrouter-vision-model")
808
- eval_model_override = req.headers.get("x-openrouter-eval-model")
809
- temp_header = req.headers.get("x-openrouter-temperature")
870
+ model_override = request.model or _override_header(req, "model")
871
+ vision_model_override = request.vision_model or _override_header(req, "vision-model")
872
+ eval_model_override = _override_header(req, "eval-model")
873
+ temp_header = _override_header(req, "temperature")
810
874
  temperature = request.temperature
811
875
  if temperature is None and temp_header:
812
876
  try:
@@ -816,7 +880,7 @@ async def annotate_from_image(
816
880
 
817
881
  if api_key == "byok":
818
882
  # BYOK mode: Create workflow and vision agent with user's Anthropic key
819
- byok_key = req.headers.get("x-anthropic-key") or req.headers.get("x-openrouter-key")
883
+ byok_key = _override_header(req, "key")
820
884
  if not byok_key:
821
885
  raise HTTPException(status_code=401, detail="Missing X-Anthropic-Key header")
822
886
 
@@ -878,26 +942,29 @@ async def annotate_from_image(
878
942
  try:
879
943
  start_time = time.time()
880
944
 
881
- # Step 1: Generate image description using vision model
882
- vision_result = await active_vision_agent.describe_image(
883
- image_data=request.image,
884
- custom_prompt=request.prompt,
885
- )
945
+ # The vision call and the annotation workflow share one usage scope
946
+ # so the reported figures cover the whole request.
947
+ with usage_scope() as usage:
948
+ # Step 1: Generate image description using vision model
949
+ vision_result = await active_vision_agent.describe_image(
950
+ image_data=request.image,
951
+ custom_prompt=request.prompt,
952
+ )
886
953
 
887
- image_description = vision_result["description"]
888
- image_metadata = vision_result["metadata"]
954
+ image_description = vision_result["description"]
955
+ image_metadata = vision_result["metadata"]
889
956
 
890
- # Step 2: Pass description through HED annotation workflow
891
- config = {"recursion_limit": 50}
957
+ # Step 2: Pass description through HED annotation workflow
958
+ config = {"recursion_limit": 50}
892
959
 
893
- final_state = await active_workflow.run(
894
- input_description=image_description,
895
- schema_version=request.schema_version,
896
- max_validation_attempts=request.max_validation_attempts,
897
- run_assessment=request.run_assessment,
898
- no_extend=request.no_extend,
899
- config=config,
900
- )
960
+ final_state = await active_workflow.run(
961
+ input_description=image_description,
962
+ schema_version=request.schema_version,
963
+ max_validation_attempts=request.max_validation_attempts,
964
+ run_assessment=request.run_assessment,
965
+ no_extend=request.no_extend,
966
+ config=config,
967
+ )
901
968
  latency_ms = int((time.time() - start_time) * 1000)
902
969
 
903
970
  # Determine overall status
@@ -909,12 +976,12 @@ async def annotate_from_image(
909
976
  # Get model info from request body, BYOK headers, or server config
910
977
  model_name = (
911
978
  request.model
912
- or req.headers.get("x-openrouter-model")
979
+ or _override_header(req, "model")
913
980
  or os.getenv("ANNOTATION_MODEL", DEFAULT_MODEL)
914
981
  )
915
982
  temperature = request.temperature
916
983
  if temperature is None:
917
- temp_header = req.headers.get("x-openrouter-temperature")
984
+ temp_header = _override_header(req, "temperature")
918
985
  if temp_header is not None:
919
986
  try:
920
987
  temperature = float(temp_header)
@@ -934,6 +1001,7 @@ async def annotate_from_image(
934
1001
  temperature=temperature,
935
1002
  latency_ms=latency_ms,
936
1003
  source="api-image", # Distinguish from text-based annotation
1004
+ usage=usage.total(),
937
1005
  )
938
1006
  await telemetry_collector.collect(event)
939
1007
 
@@ -950,6 +1018,7 @@ async def annotate_from_image(
950
1018
  assessment_feedback=final_state["assessment_feedback"],
951
1019
  status=status,
952
1020
  image_metadata=image_metadata,
1021
+ usage=_usage_summary(usage),
953
1022
  )
954
1023
 
955
1024
  except Exception as e:
@@ -965,6 +1034,7 @@ async def _collect_stream_telemetry(
965
1034
  start_time: float,
966
1035
  source: str,
967
1036
  description: str,
1037
+ usage: UsageLedger | None = None,
968
1038
  ) -> None:
969
1039
  """Collect telemetry for streaming endpoints.
970
1040
 
@@ -978,6 +1048,7 @@ async def _collect_stream_telemetry(
978
1048
  start_time: Workflow start time (from time.time())
979
1049
  source: Telemetry source identifier (e.g., "api-stream", "api-image-stream")
980
1050
  description: Input description text (or image description for image endpoints)
1051
+ usage: Usage ledger for this request, when one was collected
981
1052
  """
982
1053
  if not request.telemetry_enabled or not telemetry_collector:
983
1054
  return
@@ -987,12 +1058,12 @@ async def _collect_stream_telemetry(
987
1058
  # Get model info from request body, BYOK headers, or server config
988
1059
  model_name = (
989
1060
  request.model
990
- or req.headers.get("x-openrouter-model")
1061
+ or _override_header(req, "model")
991
1062
  or os.getenv("ANNOTATION_MODEL", DEFAULT_MODEL)
992
1063
  )
993
1064
  temperature = request.temperature
994
1065
  if temperature is None:
995
- temp_header = req.headers.get("x-openrouter-temperature")
1066
+ temp_header = _override_header(req, "temperature")
996
1067
  if temp_header is not None:
997
1068
  try:
998
1069
  temperature = float(temp_header)
@@ -1012,6 +1083,7 @@ async def _collect_stream_telemetry(
1012
1083
  temperature=temperature,
1013
1084
  latency_ms=latency_ms,
1014
1085
  source=source,
1086
+ usage=usage.total() if usage is not None else None,
1015
1087
  )
1016
1088
  await telemetry_collector.collect(event)
1017
1089
 
@@ -1044,9 +1116,9 @@ async def annotate_stream(
1044
1116
  from src.agents.state import create_initial_state
1045
1117
 
1046
1118
  # Determine which workflow to use (same logic as /annotate)
1047
- model_override = request.model or req.headers.get("x-openrouter-model")
1048
- eval_model_override = req.headers.get("x-openrouter-eval-model")
1049
- temp_header = req.headers.get("x-openrouter-temperature")
1119
+ model_override = request.model or _override_header(req, "model")
1120
+ eval_model_override = _override_header(req, "eval-model")
1121
+ temp_header = _override_header(req, "temperature")
1050
1122
  temperature = request.temperature
1051
1123
  if temperature is None and temp_header:
1052
1124
  try:
@@ -1055,7 +1127,7 @@ async def annotate_stream(
1055
1127
  pass # Invalid header value, use default temperature
1056
1128
 
1057
1129
  if api_key == "byok":
1058
- byok_key = req.headers.get("x-anthropic-key") or req.headers.get("x-openrouter-key")
1130
+ byok_key = _override_header(req, "key")
1059
1131
  if not byok_key:
1060
1132
  raise HTTPException(status_code=401, detail="Missing X-Anthropic-Key header")
1061
1133
  try:
@@ -1112,7 +1184,7 @@ async def annotate_stream(
1112
1184
  "assess": ("assessing", "Running final assessment..."),
1113
1185
  }
1114
1186
 
1115
- async def event_generator():
1187
+ async def event_generator(usage: UsageLedger):
1116
1188
  """Generate SSE events for workflow progress using LangGraph streaming."""
1117
1189
 
1118
1190
  def send_event(event_type: str, data: dict) -> str:
@@ -1211,6 +1283,9 @@ async def annotate_stream(
1211
1283
  "assessment_feedback": current_state.get("assessment_feedback", ""),
1212
1284
  "status": status,
1213
1285
  }
1286
+ usage_summary = _usage_summary(usage)
1287
+ if usage_summary is not None:
1288
+ result["usage"] = usage_summary.model_dump()
1214
1289
 
1215
1290
  yield send_event("result", result)
1216
1291
 
@@ -1223,6 +1298,7 @@ async def annotate_stream(
1223
1298
  start_time=start_time,
1224
1299
  source="api-stream",
1225
1300
  description=request.description,
1301
+ usage=usage,
1226
1302
  )
1227
1303
  except Exception:
1228
1304
  logging.warning("Telemetry collection failed for streaming request", exc_info=True)
@@ -1244,13 +1320,25 @@ async def annotate_stream(
1244
1320
  start_time=start_time,
1245
1321
  source="api-stream",
1246
1322
  description=request.description,
1323
+ usage=usage,
1247
1324
  )
1248
1325
  except Exception:
1249
1326
  logging.warning("Telemetry collection failed on error", exc_info=True)
1250
1327
  yield send_event("done", {"message": "Workflow ended with error"})
1251
1328
 
1329
+ async def streamed_events():
1330
+ """Hold one usage scope open for the whole stream.
1331
+
1332
+ The scope lives outside the generator that runs the workflow so that
1333
+ every LLM call made while the response streams is attributed to this
1334
+ request.
1335
+ """
1336
+ with usage_scope() as usage:
1337
+ async for chunk in event_generator(usage):
1338
+ yield chunk
1339
+
1252
1340
  return StreamingResponse(
1253
- event_generator(),
1341
+ streamed_events(),
1254
1342
  media_type="text/event-stream",
1255
1343
  headers={
1256
1344
  "Cache-Control": "no-cache",
@@ -1289,10 +1377,10 @@ async def annotate_from_image_stream(
1289
1377
  from src.agents.state import create_initial_state
1290
1378
 
1291
1379
  # Determine which workflow and vision agent to use (same logic as /annotate-from-image)
1292
- model_override = request.model or req.headers.get("x-openrouter-model")
1293
- vision_model_override = request.vision_model or req.headers.get("x-openrouter-vision-model")
1294
- eval_model_override = req.headers.get("x-openrouter-eval-model")
1295
- temp_header = req.headers.get("x-openrouter-temperature")
1380
+ model_override = request.model or _override_header(req, "model")
1381
+ vision_model_override = request.vision_model or _override_header(req, "vision-model")
1382
+ eval_model_override = _override_header(req, "eval-model")
1383
+ temp_header = _override_header(req, "temperature")
1296
1384
  temperature = request.temperature
1297
1385
  if temperature is None and temp_header:
1298
1386
  try:
@@ -1301,7 +1389,7 @@ async def annotate_from_image_stream(
1301
1389
  pass
1302
1390
 
1303
1391
  if api_key == "byok":
1304
- byok_key = req.headers.get("x-anthropic-key") or req.headers.get("x-openrouter-key")
1392
+ byok_key = _override_header(req, "key")
1305
1393
  if not byok_key:
1306
1394
  raise HTTPException(status_code=401, detail="Missing X-Anthropic-Key header")
1307
1395
  try:
@@ -1365,7 +1453,7 @@ async def annotate_from_image_stream(
1365
1453
  "assess": ("assessing", "Running final assessment..."),
1366
1454
  }
1367
1455
 
1368
- async def event_generator():
1456
+ async def event_generator(usage: UsageLedger):
1369
1457
  """Generate SSE events for image annotation workflow progress."""
1370
1458
 
1371
1459
  def send_event(event_type: str, data: dict) -> str:
@@ -1496,6 +1584,9 @@ async def annotate_from_image_stream(
1496
1584
  "status": status,
1497
1585
  "image_metadata": image_metadata,
1498
1586
  }
1587
+ usage_summary = _usage_summary(usage)
1588
+ if usage_summary is not None:
1589
+ result["usage"] = usage_summary.model_dump()
1499
1590
 
1500
1591
  yield send_event("result", result)
1501
1592
 
@@ -1508,6 +1599,7 @@ async def annotate_from_image_stream(
1508
1599
  start_time=start_time,
1509
1600
  source="api-image-stream",
1510
1601
  description=image_description,
1602
+ usage=usage,
1511
1603
  )
1512
1604
  except Exception:
1513
1605
  logging.debug(
@@ -1531,13 +1623,25 @@ async def annotate_from_image_stream(
1531
1623
  start_time=start_time,
1532
1624
  source="api-image-stream",
1533
1625
  description=image_description or "image-annotation-failed",
1626
+ usage=usage,
1534
1627
  )
1535
1628
  except Exception:
1536
1629
  logging.warning("Telemetry collection failed on image error", exc_info=True)
1537
1630
  yield send_event("done", {"message": "Workflow ended with error"})
1538
1631
 
1632
+ async def streamed_events():
1633
+ """Hold one usage scope open for the whole stream.
1634
+
1635
+ The scope lives outside the generator that runs the workflow so that
1636
+ every LLM call made while the response streams is attributed to this
1637
+ request.
1638
+ """
1639
+ with usage_scope() as usage:
1640
+ async for chunk in event_generator(usage):
1641
+ yield chunk
1642
+
1539
1643
  return StreamingResponse(
1540
- event_generator(),
1644
+ streamed_events(),
1541
1645
  media_type="text/event-stream",
1542
1646
  headers={
1543
1647
  "Cache-Control": "no-cache",
@@ -1608,7 +1712,6 @@ async def submit_feedback(request: FeedbackRequest) -> FeedbackResponse:
1608
1712
  Returns:
1609
1713
  FeedbackResponse with feedback ID and status
1610
1714
  """
1611
- from datetime import datetime
1612
1715
  from uuid import uuid4
1613
1716
 
1614
1717
  try:
@@ -1683,6 +1786,7 @@ async def submit_feedback(request: FeedbackRequest) -> FeedbackResponse:
1683
1786
  model=model,
1684
1787
  temperature=0.1,
1685
1788
  max_tokens=1000,
1789
+ role="triage",
1686
1790
  )
1687
1791
 
1688
1792
  # Create and run triage agent
@@ -1737,6 +1841,39 @@ async def submit_feedback(request: FeedbackRequest) -> FeedbackResponse:
1737
1841
  ) from e
1738
1842
 
1739
1843
 
1844
+ @app.get("/metrics", response_model=MetricsResponse)
1845
+ async def metrics(api_key: str = Depends(api_key_auth)) -> MetricsResponse:
1846
+ """Report LLM token usage, cost, and prompt-cache savings since startup.
1847
+
1848
+ These are server-wide operator figures, so BYOK callers are refused: a
1849
+ BYOK request gets its own numbers in the ``usage`` field of its
1850
+ annotation response instead.
1851
+
1852
+ Args:
1853
+ api_key: Authentication result (injected by dependency)
1854
+
1855
+ Returns:
1856
+ Totals since startup, broken down by agent role and by model
1857
+
1858
+ Raises:
1859
+ HTTPException: 403 when authenticated via BYOK
1860
+ """
1861
+ if api_key == "byok":
1862
+ raise HTTPException(
1863
+ status_code=403,
1864
+ detail="Server metrics require a server API key; "
1865
+ "per-request usage is returned in the annotation response.",
1866
+ )
1867
+
1868
+ snapshot = process_ledger().snapshot()
1869
+ return MetricsResponse(
1870
+ since=_startup_time,
1871
+ total=UsageSummary(**snapshot["total"]),
1872
+ by_role={role: UsageSummary(**totals) for role, totals in snapshot["by_role"].items()},
1873
+ by_model={model: UsageSummary(**totals) for model, totals in snapshot["by_model"].items()},
1874
+ )
1875
+
1876
+
1740
1877
  @app.get("/version")
1741
1878
  async def get_version():
1742
1879
  """Get API version information.