hedit 0.7.11.dev3__tar.gz → 0.7.11.dev5__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 (91) hide show
  1. {hedit-0.7.11.dev3/hedit.egg-info → hedit-0.7.11.dev5}/PKG-INFO +1 -1
  2. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/README.md +3 -0
  3. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5/hedit.egg-info}/PKG-INFO +1 -1
  4. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/hedit.egg-info/SOURCES.txt +3 -0
  5. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/pyproject.toml +1 -1
  6. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/api/main.py +189 -59
  7. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/api/models.py +67 -0
  8. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/client.py +5 -5
  9. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/local_executor.py +78 -33
  10. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/output.py +83 -11
  11. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/scripts/process_feedback.py +1 -0
  12. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/telemetry/schema.py +59 -11
  13. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/anthropic_llm.py +120 -20
  14. hedit-0.7.11.dev5/src/utils/llm_usage.py +345 -0
  15. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/version.py +1 -1
  16. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_annotation_agent.py +55 -0
  17. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_anthropic_llm.py +137 -0
  18. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_api_endpoints.py +153 -0
  19. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_cli_client.py +14 -2
  20. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_cli_integration.py +3 -3
  21. hedit-0.7.11.dev5/tests/test_cli_usage_report.py +209 -0
  22. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_integration_anthropic.py +102 -0
  23. hedit-0.7.11.dev5/tests/test_llm_usage.py +294 -0
  24. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_telemetry.py +99 -0
  25. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/LICENSE +0 -0
  26. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/PKG_README.md +0 -0
  27. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/hedit.egg-info/dependency_links.txt +0 -0
  28. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/hedit.egg-info/entry_points.txt +0 -0
  29. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/hedit.egg-info/requires.txt +0 -0
  30. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/hedit.egg-info/top_level.txt +0 -0
  31. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/setup.cfg +0 -0
  32. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/__init__.py +0 -0
  33. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/__init__.py +0 -0
  34. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/annotation_agent.py +0 -0
  35. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/assessment_agent.py +0 -0
  36. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/evaluation_agent.py +0 -0
  37. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/feedback_summarizer.py +0 -0
  38. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/feedback_triage_agent.py +0 -0
  39. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/state.py +0 -0
  40. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/validation_agent.py +0 -0
  41. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/vision_agent.py +0 -0
  42. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/workflow.py +0 -0
  43. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/api/__init__.py +0 -0
  44. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/api/security.py +0 -0
  45. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/__init__.py +0 -0
  46. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/api_executor.py +0 -0
  47. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/commands/__init__.py +0 -0
  48. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/commands/lsp.py +0 -0
  49. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/config.py +0 -0
  50. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/executor.py +0 -0
  51. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/main.py +0 -0
  52. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/data/__init__.py +0 -0
  53. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/data/hed-docs/02_Terminology.md +0 -0
  54. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/data/hed-docs/HedAnnotationSemantics.md +0 -0
  55. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/data/hed-docs/manifest.json +0 -0
  56. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/lsp/__init__.py +0 -0
  57. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/lsp/client.py +0 -0
  58. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/lsp/daemon.py +0 -0
  59. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/lsp/protocol.py +0 -0
  60. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/scripts/__init__.py +0 -0
  61. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/telemetry/__init__.py +0 -0
  62. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/telemetry/collector.py +0 -0
  63. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/telemetry/storage.py +0 -0
  64. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/__init__.py +0 -0
  65. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/error_remediation.py +0 -0
  66. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/github_client.py +0 -0
  67. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/hed_comprehensive_guide.py +0 -0
  68. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/hed_docs_loader.py +0 -0
  69. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/image_processing.py +0 -0
  70. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/json_schema_loader.py +0 -0
  71. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/schema_loader.py +0 -0
  72. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/validation/__init__.py +0 -0
  73. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/validation/hed_lsp.py +0 -0
  74. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/validation/hed_validator.py +0 -0
  75. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_cli_config.py +0 -0
  76. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_cli_main.py +0 -0
  77. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_comprehensive_guide.py +0 -0
  78. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_error_remediation.py +0 -0
  79. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_feedback_integration.py +0 -0
  80. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_feedback_triage.py +0 -0
  81. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_fetch_hed_docs.py +0 -0
  82. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_github_client.py +0 -0
  83. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_hed_docs_loader.py +0 -0
  84. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_json_schema_loader.py +0 -0
  85. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_no_extend_propagation.py +0 -0
  86. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_schema_loader.py +0 -0
  87. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_security.py +0 -0
  88. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_state.py +0 -0
  89. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_validation.py +0 -0
  90. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_validation_agent.py +0 -0
  91. {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/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.dev5
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,7 @@ The agents work in feedback loops, automatically refining the annotation until i
83
84
 
84
85
  ## Documentation
85
86
 
87
+ - [Prompt Caching and Usage Reporting](docs/prompt-caching.md) - What HEDit caches, what it saves, and where to see the numbers
86
88
  - [HED Standard](https://hedtags.org) - Learn about HED annotations
87
89
  - [GitHub Issues](https://github.com/Annotation-Garden/HEDit/issues) - Report bugs or request features
88
90
 
@@ -234,6 +236,7 @@ uvicorn src.api.main:app --reload --host 0.0.0.0 --port 38427
234
236
  - `POST /annotate`: Generate HED annotation from natural language
235
237
  - `POST /validate`: Validate HED annotation
236
238
  - `GET /health`: Health check
239
+ - `GET /metrics`: Token use, cost, and prompt-cache savings since startup (server API key required)
237
240
  - API URL: `http://localhost:38427`
238
241
 
239
242
  ## 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.dev5
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.dev5"
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,6 +32,8 @@ 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
  )
@@ -38,6 +41,7 @@ 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
43
  from src.utils.anthropic_llm import DEFAULT_MODEL, create_anthropic_llm, normalize_model
44
+ from src.utils.llm_usage import UsageLedger, process_ledger, usage_scope
41
45
  from src.utils.schema_loader import HedSchemaLoader
42
46
  from src.validation.hed_validator import HedPythonValidator
43
47
 
@@ -56,6 +60,7 @@ lsp_client: HedLspClient | None = None
56
60
 
57
61
  # Telemetry collector (initialized in lifespan)
58
62
  telemetry_collector: TelemetryCollector | None = None
63
+ _startup_time: str = datetime.now(UTC).isoformat()
59
64
 
60
65
  # Cache for BYOK configuration
61
66
  _byok_config: dict = {}
@@ -100,6 +105,41 @@ def _describe_llm_error(exc: Exception) -> tuple[int, str, str]:
100
105
  _USE_GLOBAL_LSP: HedLspClient = object() # type: ignore[assignment]
101
106
 
102
107
 
108
+ def _usage_summary(ledger: UsageLedger) -> UsageSummary | None:
109
+ """Build the response's usage figures from a request's usage ledger.
110
+
111
+ Returns None when no LLM call was recorded (a fully cached or failed
112
+ request), so clients can tell "no calls" from "zero cost".
113
+
114
+ Args:
115
+ ledger: Ledger collected for one request
116
+
117
+ Returns:
118
+ UsageSummary, or None when nothing was recorded
119
+ """
120
+ totals = ledger.total()
121
+ if totals.calls == 0:
122
+ return None
123
+ return UsageSummary(**totals.as_dict())
124
+
125
+
126
+ def _override_header(req: Request, name: str) -> str | None:
127
+ """Read a per-request override header, preferring the X-Anthropic-* spelling.
128
+
129
+ The X-OpenRouter-* names are the wire spelling from before the Anthropic
130
+ migration. They remain accepted indefinitely so that cached frontends and
131
+ third-party clients keep working; current clients send X-Anthropic-*.
132
+
133
+ Args:
134
+ req: Incoming request
135
+ name: Header suffix, e.g. "model" for X-Anthropic-Model
136
+
137
+ Returns:
138
+ Header value, or None when neither spelling is present
139
+ """
140
+ return req.headers.get(f"x-anthropic-{name}") or req.headers.get(f"x-openrouter-{name}")
141
+
142
+
103
143
  def _resolve_lsp_client(
104
144
  explicit: HedLspClient | None,
105
145
  ) -> HedLspClient | None:
@@ -167,6 +207,7 @@ def create_anthropic_workflow(
167
207
  model=actual_annotation_model,
168
208
  api_key=api_key,
169
209
  temperature=actual_temperature,
210
+ role="annotation",
170
211
  )
171
212
  # Evaluation / assessment / feedback / keyword extraction are short
172
213
  # structured tasks; reasoning adds 5-10 s per call without
@@ -176,18 +217,21 @@ def create_anthropic_workflow(
176
217
  api_key=api_key,
177
218
  temperature=actual_temperature,
178
219
  disable_reasoning=True,
220
+ role="evaluation",
179
221
  )
180
222
  assessment_llm = create_anthropic_llm(
181
223
  model=actual_eval_model,
182
224
  api_key=api_key,
183
225
  temperature=actual_temperature,
184
226
  disable_reasoning=True,
227
+ role="assessment",
185
228
  )
186
229
  feedback_llm = create_anthropic_llm(
187
230
  model=actual_eval_model,
188
231
  api_key=api_key,
189
232
  temperature=actual_temperature,
190
233
  disable_reasoning=True,
234
+ role="feedback",
191
235
  )
192
236
  # Keyword extraction (#148): use the fast annotation model with
193
237
  # reasoning explicitly disabled and a small token cap. The
@@ -199,6 +243,7 @@ def create_anthropic_workflow(
199
243
  temperature=actual_temperature,
200
244
  max_tokens=200,
201
245
  disable_reasoning=True,
246
+ role="keyword",
202
247
  )
203
248
 
204
249
  # Create and return workflow
@@ -283,6 +328,7 @@ def create_vision_agent(
283
328
  model=actual_model,
284
329
  api_key=api_key,
285
330
  temperature=actual_temperature,
331
+ role="vision",
286
332
  )
287
333
 
288
334
  return VisionAgent(llm=vision_llm)
@@ -477,6 +523,9 @@ async def lifespan(app: FastAPI):
477
523
  exc_info=True,
478
524
  )
479
525
 
526
+ global _startup_time
527
+ _startup_time = datetime.now(UTC).isoformat()
528
+
480
529
  # Initialize telemetry collector
481
530
  global telemetry_collector
482
531
  # Use /app/telemetry in Docker, otherwise use local .hedit/telemetry
@@ -555,13 +604,18 @@ app.add_middleware(
555
604
  "X-Requested-With",
556
605
  "X-API-Key",
557
606
  "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
607
+ "X-Anthropic-Model", # Model override
608
+ "X-Anthropic-Eval-Model", # Eval model override
609
+ "X-Anthropic-Vision-Model", # Vision model override
610
+ "X-Anthropic-Temperature", # Temperature override
611
+ # Legacy X-OpenRouter-* spellings, still accepted as transport
612
+ "X-OpenRouter-Key",
613
+ "X-OpenRouter-Model",
614
+ "X-OpenRouter-Vision-Model",
561
615
  "X-OpenRouter-Vision-Provider", # Legacy, ignored
562
616
  "X-OpenRouter-Provider", # Legacy, ignored
563
- "X-OpenRouter-Temperature", # Temperature override
564
- "X-OpenRouter-Eval-Model", # Eval model override
617
+ "X-OpenRouter-Temperature",
618
+ "X-OpenRouter-Eval-Model",
565
619
  "X-OpenRouter-Eval-Provider", # Legacy, ignored
566
620
  "X-User-Id", # Legacy, ignored
567
621
  ],
@@ -646,9 +700,9 @@ async def annotate(
646
700
  """
647
701
  # Determine which workflow to use
648
702
  # 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")
703
+ model_override = request.model or _override_header(req, "model")
704
+ eval_model_override = _override_header(req, "eval-model")
705
+ temp_header = _override_header(req, "temperature")
652
706
  temperature = request.temperature
653
707
  if temperature is None and temp_header:
654
708
  try:
@@ -658,7 +712,7 @@ async def annotate(
658
712
 
659
713
  if api_key == "byok":
660
714
  # 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")
715
+ byok_key = _override_header(req, "key")
662
716
  if not byok_key:
663
717
  raise HTTPException(status_code=401, detail="Missing X-Anthropic-Key header")
664
718
 
@@ -707,14 +761,15 @@ async def annotate(
707
761
  config = {"recursion_limit": 50}
708
762
 
709
763
  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
- )
764
+ with usage_scope() as usage:
765
+ final_state = await active_workflow.run(
766
+ input_description=request.description,
767
+ schema_version=request.schema_version,
768
+ max_validation_attempts=request.max_validation_attempts,
769
+ run_assessment=request.run_assessment,
770
+ no_extend=request.no_extend,
771
+ config=config,
772
+ )
718
773
  latency_ms = int((time.time() - start_time) * 1000)
719
774
 
720
775
  # Determine overall status
@@ -728,12 +783,12 @@ async def annotate(
728
783
  # Get model info from request body, BYOK headers, or server config
729
784
  model_name = (
730
785
  request.model
731
- or req.headers.get("x-openrouter-model")
786
+ or _override_header(req, "model")
732
787
  or os.getenv("ANNOTATION_MODEL", DEFAULT_MODEL)
733
788
  )
734
789
  temperature = request.temperature
735
790
  if temperature is None:
736
- temp_header = req.headers.get("x-openrouter-temperature")
791
+ temp_header = _override_header(req, "temperature")
737
792
  if temp_header is not None:
738
793
  try:
739
794
  temperature = float(temp_header)
@@ -753,6 +808,7 @@ async def annotate(
753
808
  temperature=temperature,
754
809
  latency_ms=latency_ms,
755
810
  source="api",
811
+ usage=usage.total(),
756
812
  )
757
813
  await telemetry_collector.collect(event)
758
814
 
@@ -767,6 +823,7 @@ async def annotate(
767
823
  evaluation_feedback=final_state["evaluation_feedback"],
768
824
  assessment_feedback=final_state["assessment_feedback"],
769
825
  status=status,
826
+ usage=_usage_summary(usage),
770
827
  )
771
828
 
772
829
  except Exception as e:
@@ -803,10 +860,10 @@ async def annotate_from_image(
803
860
  """
804
861
  # Determine which workflow and vision agent to use
805
862
  # 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")
863
+ model_override = request.model or _override_header(req, "model")
864
+ vision_model_override = request.vision_model or _override_header(req, "vision-model")
865
+ eval_model_override = _override_header(req, "eval-model")
866
+ temp_header = _override_header(req, "temperature")
810
867
  temperature = request.temperature
811
868
  if temperature is None and temp_header:
812
869
  try:
@@ -816,7 +873,7 @@ async def annotate_from_image(
816
873
 
817
874
  if api_key == "byok":
818
875
  # 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")
876
+ byok_key = _override_header(req, "key")
820
877
  if not byok_key:
821
878
  raise HTTPException(status_code=401, detail="Missing X-Anthropic-Key header")
822
879
 
@@ -878,26 +935,29 @@ async def annotate_from_image(
878
935
  try:
879
936
  start_time = time.time()
880
937
 
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
- )
938
+ # The vision call and the annotation workflow share one usage scope
939
+ # so the reported figures cover the whole request.
940
+ with usage_scope() as usage:
941
+ # Step 1: Generate image description using vision model
942
+ vision_result = await active_vision_agent.describe_image(
943
+ image_data=request.image,
944
+ custom_prompt=request.prompt,
945
+ )
886
946
 
887
- image_description = vision_result["description"]
888
- image_metadata = vision_result["metadata"]
947
+ image_description = vision_result["description"]
948
+ image_metadata = vision_result["metadata"]
889
949
 
890
- # Step 2: Pass description through HED annotation workflow
891
- config = {"recursion_limit": 50}
950
+ # Step 2: Pass description through HED annotation workflow
951
+ config = {"recursion_limit": 50}
892
952
 
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
- )
953
+ final_state = await active_workflow.run(
954
+ input_description=image_description,
955
+ schema_version=request.schema_version,
956
+ max_validation_attempts=request.max_validation_attempts,
957
+ run_assessment=request.run_assessment,
958
+ no_extend=request.no_extend,
959
+ config=config,
960
+ )
901
961
  latency_ms = int((time.time() - start_time) * 1000)
902
962
 
903
963
  # Determine overall status
@@ -909,12 +969,12 @@ async def annotate_from_image(
909
969
  # Get model info from request body, BYOK headers, or server config
910
970
  model_name = (
911
971
  request.model
912
- or req.headers.get("x-openrouter-model")
972
+ or _override_header(req, "model")
913
973
  or os.getenv("ANNOTATION_MODEL", DEFAULT_MODEL)
914
974
  )
915
975
  temperature = request.temperature
916
976
  if temperature is None:
917
- temp_header = req.headers.get("x-openrouter-temperature")
977
+ temp_header = _override_header(req, "temperature")
918
978
  if temp_header is not None:
919
979
  try:
920
980
  temperature = float(temp_header)
@@ -934,6 +994,7 @@ async def annotate_from_image(
934
994
  temperature=temperature,
935
995
  latency_ms=latency_ms,
936
996
  source="api-image", # Distinguish from text-based annotation
997
+ usage=usage.total(),
937
998
  )
938
999
  await telemetry_collector.collect(event)
939
1000
 
@@ -950,6 +1011,7 @@ async def annotate_from_image(
950
1011
  assessment_feedback=final_state["assessment_feedback"],
951
1012
  status=status,
952
1013
  image_metadata=image_metadata,
1014
+ usage=_usage_summary(usage),
953
1015
  )
954
1016
 
955
1017
  except Exception as e:
@@ -965,6 +1027,7 @@ async def _collect_stream_telemetry(
965
1027
  start_time: float,
966
1028
  source: str,
967
1029
  description: str,
1030
+ usage: UsageLedger | None = None,
968
1031
  ) -> None:
969
1032
  """Collect telemetry for streaming endpoints.
970
1033
 
@@ -978,6 +1041,7 @@ async def _collect_stream_telemetry(
978
1041
  start_time: Workflow start time (from time.time())
979
1042
  source: Telemetry source identifier (e.g., "api-stream", "api-image-stream")
980
1043
  description: Input description text (or image description for image endpoints)
1044
+ usage: Usage ledger for this request, when one was collected
981
1045
  """
982
1046
  if not request.telemetry_enabled or not telemetry_collector:
983
1047
  return
@@ -987,12 +1051,12 @@ async def _collect_stream_telemetry(
987
1051
  # Get model info from request body, BYOK headers, or server config
988
1052
  model_name = (
989
1053
  request.model
990
- or req.headers.get("x-openrouter-model")
1054
+ or _override_header(req, "model")
991
1055
  or os.getenv("ANNOTATION_MODEL", DEFAULT_MODEL)
992
1056
  )
993
1057
  temperature = request.temperature
994
1058
  if temperature is None:
995
- temp_header = req.headers.get("x-openrouter-temperature")
1059
+ temp_header = _override_header(req, "temperature")
996
1060
  if temp_header is not None:
997
1061
  try:
998
1062
  temperature = float(temp_header)
@@ -1012,6 +1076,7 @@ async def _collect_stream_telemetry(
1012
1076
  temperature=temperature,
1013
1077
  latency_ms=latency_ms,
1014
1078
  source=source,
1079
+ usage=usage.total() if usage is not None else None,
1015
1080
  )
1016
1081
  await telemetry_collector.collect(event)
1017
1082
 
@@ -1044,9 +1109,9 @@ async def annotate_stream(
1044
1109
  from src.agents.state import create_initial_state
1045
1110
 
1046
1111
  # 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")
1112
+ model_override = request.model or _override_header(req, "model")
1113
+ eval_model_override = _override_header(req, "eval-model")
1114
+ temp_header = _override_header(req, "temperature")
1050
1115
  temperature = request.temperature
1051
1116
  if temperature is None and temp_header:
1052
1117
  try:
@@ -1055,7 +1120,7 @@ async def annotate_stream(
1055
1120
  pass # Invalid header value, use default temperature
1056
1121
 
1057
1122
  if api_key == "byok":
1058
- byok_key = req.headers.get("x-anthropic-key") or req.headers.get("x-openrouter-key")
1123
+ byok_key = _override_header(req, "key")
1059
1124
  if not byok_key:
1060
1125
  raise HTTPException(status_code=401, detail="Missing X-Anthropic-Key header")
1061
1126
  try:
@@ -1112,7 +1177,7 @@ async def annotate_stream(
1112
1177
  "assess": ("assessing", "Running final assessment..."),
1113
1178
  }
1114
1179
 
1115
- async def event_generator():
1180
+ async def event_generator(usage: UsageLedger):
1116
1181
  """Generate SSE events for workflow progress using LangGraph streaming."""
1117
1182
 
1118
1183
  def send_event(event_type: str, data: dict) -> str:
@@ -1211,6 +1276,9 @@ async def annotate_stream(
1211
1276
  "assessment_feedback": current_state.get("assessment_feedback", ""),
1212
1277
  "status": status,
1213
1278
  }
1279
+ usage_summary = _usage_summary(usage)
1280
+ if usage_summary is not None:
1281
+ result["usage"] = usage_summary.model_dump()
1214
1282
 
1215
1283
  yield send_event("result", result)
1216
1284
 
@@ -1223,6 +1291,7 @@ async def annotate_stream(
1223
1291
  start_time=start_time,
1224
1292
  source="api-stream",
1225
1293
  description=request.description,
1294
+ usage=usage,
1226
1295
  )
1227
1296
  except Exception:
1228
1297
  logging.warning("Telemetry collection failed for streaming request", exc_info=True)
@@ -1244,13 +1313,25 @@ async def annotate_stream(
1244
1313
  start_time=start_time,
1245
1314
  source="api-stream",
1246
1315
  description=request.description,
1316
+ usage=usage,
1247
1317
  )
1248
1318
  except Exception:
1249
1319
  logging.warning("Telemetry collection failed on error", exc_info=True)
1250
1320
  yield send_event("done", {"message": "Workflow ended with error"})
1251
1321
 
1322
+ async def streamed_events():
1323
+ """Hold one usage scope open for the whole stream.
1324
+
1325
+ The scope lives outside the generator that runs the workflow so that
1326
+ every LLM call made while the response streams is attributed to this
1327
+ request.
1328
+ """
1329
+ with usage_scope() as usage:
1330
+ async for chunk in event_generator(usage):
1331
+ yield chunk
1332
+
1252
1333
  return StreamingResponse(
1253
- event_generator(),
1334
+ streamed_events(),
1254
1335
  media_type="text/event-stream",
1255
1336
  headers={
1256
1337
  "Cache-Control": "no-cache",
@@ -1289,10 +1370,10 @@ async def annotate_from_image_stream(
1289
1370
  from src.agents.state import create_initial_state
1290
1371
 
1291
1372
  # 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")
1373
+ model_override = request.model or _override_header(req, "model")
1374
+ vision_model_override = request.vision_model or _override_header(req, "vision-model")
1375
+ eval_model_override = _override_header(req, "eval-model")
1376
+ temp_header = _override_header(req, "temperature")
1296
1377
  temperature = request.temperature
1297
1378
  if temperature is None and temp_header:
1298
1379
  try:
@@ -1301,7 +1382,7 @@ async def annotate_from_image_stream(
1301
1382
  pass
1302
1383
 
1303
1384
  if api_key == "byok":
1304
- byok_key = req.headers.get("x-anthropic-key") or req.headers.get("x-openrouter-key")
1385
+ byok_key = _override_header(req, "key")
1305
1386
  if not byok_key:
1306
1387
  raise HTTPException(status_code=401, detail="Missing X-Anthropic-Key header")
1307
1388
  try:
@@ -1365,7 +1446,7 @@ async def annotate_from_image_stream(
1365
1446
  "assess": ("assessing", "Running final assessment..."),
1366
1447
  }
1367
1448
 
1368
- async def event_generator():
1449
+ async def event_generator(usage: UsageLedger):
1369
1450
  """Generate SSE events for image annotation workflow progress."""
1370
1451
 
1371
1452
  def send_event(event_type: str, data: dict) -> str:
@@ -1496,6 +1577,9 @@ async def annotate_from_image_stream(
1496
1577
  "status": status,
1497
1578
  "image_metadata": image_metadata,
1498
1579
  }
1580
+ usage_summary = _usage_summary(usage)
1581
+ if usage_summary is not None:
1582
+ result["usage"] = usage_summary.model_dump()
1499
1583
 
1500
1584
  yield send_event("result", result)
1501
1585
 
@@ -1508,6 +1592,7 @@ async def annotate_from_image_stream(
1508
1592
  start_time=start_time,
1509
1593
  source="api-image-stream",
1510
1594
  description=image_description,
1595
+ usage=usage,
1511
1596
  )
1512
1597
  except Exception:
1513
1598
  logging.debug(
@@ -1531,13 +1616,25 @@ async def annotate_from_image_stream(
1531
1616
  start_time=start_time,
1532
1617
  source="api-image-stream",
1533
1618
  description=image_description or "image-annotation-failed",
1619
+ usage=usage,
1534
1620
  )
1535
1621
  except Exception:
1536
1622
  logging.warning("Telemetry collection failed on image error", exc_info=True)
1537
1623
  yield send_event("done", {"message": "Workflow ended with error"})
1538
1624
 
1625
+ async def streamed_events():
1626
+ """Hold one usage scope open for the whole stream.
1627
+
1628
+ The scope lives outside the generator that runs the workflow so that
1629
+ every LLM call made while the response streams is attributed to this
1630
+ request.
1631
+ """
1632
+ with usage_scope() as usage:
1633
+ async for chunk in event_generator(usage):
1634
+ yield chunk
1635
+
1539
1636
  return StreamingResponse(
1540
- event_generator(),
1637
+ streamed_events(),
1541
1638
  media_type="text/event-stream",
1542
1639
  headers={
1543
1640
  "Cache-Control": "no-cache",
@@ -1608,7 +1705,6 @@ async def submit_feedback(request: FeedbackRequest) -> FeedbackResponse:
1608
1705
  Returns:
1609
1706
  FeedbackResponse with feedback ID and status
1610
1707
  """
1611
- from datetime import datetime
1612
1708
  from uuid import uuid4
1613
1709
 
1614
1710
  try:
@@ -1683,6 +1779,7 @@ async def submit_feedback(request: FeedbackRequest) -> FeedbackResponse:
1683
1779
  model=model,
1684
1780
  temperature=0.1,
1685
1781
  max_tokens=1000,
1782
+ role="triage",
1686
1783
  )
1687
1784
 
1688
1785
  # Create and run triage agent
@@ -1737,6 +1834,39 @@ async def submit_feedback(request: FeedbackRequest) -> FeedbackResponse:
1737
1834
  ) from e
1738
1835
 
1739
1836
 
1837
+ @app.get("/metrics", response_model=MetricsResponse)
1838
+ async def metrics(api_key: str = Depends(api_key_auth)) -> MetricsResponse:
1839
+ """Report LLM token usage, cost, and prompt-cache savings since startup.
1840
+
1841
+ These are server-wide operator figures, so BYOK callers are refused: a
1842
+ BYOK request gets its own numbers in the ``usage`` field of its
1843
+ annotation response instead.
1844
+
1845
+ Args:
1846
+ api_key: Authentication result (injected by dependency)
1847
+
1848
+ Returns:
1849
+ Totals since startup, broken down by agent role and by model
1850
+
1851
+ Raises:
1852
+ HTTPException: 403 when authenticated via BYOK
1853
+ """
1854
+ if api_key == "byok":
1855
+ raise HTTPException(
1856
+ status_code=403,
1857
+ detail="Server metrics require a server API key; "
1858
+ "per-request usage is returned in the annotation response.",
1859
+ )
1860
+
1861
+ snapshot = process_ledger().snapshot()
1862
+ return MetricsResponse(
1863
+ since=_startup_time,
1864
+ total=UsageSummary(**snapshot["total"]),
1865
+ by_role={role: UsageSummary(**totals) for role, totals in snapshot["by_role"].items()},
1866
+ by_model={model: UsageSummary(**totals) for model, totals in snapshot["by_model"].items()},
1867
+ )
1868
+
1869
+
1740
1870
  @app.get("/version")
1741
1871
  async def get_version():
1742
1872
  """Get API version information.