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.
- {hedit-0.7.11.dev3/hedit.egg-info → hedit-0.7.11.dev5}/PKG-INFO +1 -1
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/README.md +3 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5/hedit.egg-info}/PKG-INFO +1 -1
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/hedit.egg-info/SOURCES.txt +3 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/pyproject.toml +1 -1
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/api/main.py +189 -59
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/api/models.py +67 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/client.py +5 -5
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/local_executor.py +78 -33
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/output.py +83 -11
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/scripts/process_feedback.py +1 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/telemetry/schema.py +59 -11
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/anthropic_llm.py +120 -20
- hedit-0.7.11.dev5/src/utils/llm_usage.py +345 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/version.py +1 -1
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_annotation_agent.py +55 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_anthropic_llm.py +137 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_api_endpoints.py +153 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_cli_client.py +14 -2
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_cli_integration.py +3 -3
- hedit-0.7.11.dev5/tests/test_cli_usage_report.py +209 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_integration_anthropic.py +102 -0
- hedit-0.7.11.dev5/tests/test_llm_usage.py +294 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_telemetry.py +99 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/LICENSE +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/PKG_README.md +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/hedit.egg-info/dependency_links.txt +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/hedit.egg-info/entry_points.txt +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/hedit.egg-info/requires.txt +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/hedit.egg-info/top_level.txt +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/setup.cfg +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/__init__.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/__init__.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/annotation_agent.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/assessment_agent.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/evaluation_agent.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/feedback_summarizer.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/feedback_triage_agent.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/state.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/validation_agent.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/vision_agent.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/agents/workflow.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/api/__init__.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/api/security.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/__init__.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/api_executor.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/commands/__init__.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/commands/lsp.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/config.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/executor.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/cli/main.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/data/__init__.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/data/hed-docs/02_Terminology.md +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/data/hed-docs/HedAnnotationSemantics.md +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/data/hed-docs/manifest.json +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/lsp/__init__.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/lsp/client.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/lsp/daemon.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/lsp/protocol.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/scripts/__init__.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/telemetry/__init__.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/telemetry/collector.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/telemetry/storage.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/__init__.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/error_remediation.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/github_client.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/hed_comprehensive_guide.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/hed_docs_loader.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/image_processing.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/json_schema_loader.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/utils/schema_loader.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/validation/__init__.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/validation/hed_lsp.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/src/validation/hed_validator.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_cli_config.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_cli_main.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_comprehensive_guide.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_error_remediation.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_feedback_integration.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_feedback_triage.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_fetch_hed_docs.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_github_client.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_hed_docs_loader.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_json_schema_loader.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_no_extend_propagation.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_schema_loader.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_security.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_state.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_validation.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_validation_agent.py +0 -0
- {hedit-0.7.11.dev3 → hedit-0.7.11.dev5}/tests/test_version.py +0 -0
|
@@ -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
|
|
@@ -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
|
|
@@ -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-
|
|
559
|
-
"X-
|
|
560
|
-
"X-
|
|
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",
|
|
564
|
-
"X-OpenRouter-Eval-Model",
|
|
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
|
|
650
|
-
eval_model_override = req
|
|
651
|
-
temp_header = req
|
|
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
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
|
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
|
|
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
|
|
807
|
-
vision_model_override = request.vision_model or req
|
|
808
|
-
eval_model_override = req
|
|
809
|
-
temp_header = req
|
|
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
|
|
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
|
-
#
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
888
|
-
|
|
947
|
+
image_description = vision_result["description"]
|
|
948
|
+
image_metadata = vision_result["metadata"]
|
|
889
949
|
|
|
890
|
-
|
|
891
|
-
|
|
950
|
+
# Step 2: Pass description through HED annotation workflow
|
|
951
|
+
config = {"recursion_limit": 50}
|
|
892
952
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1048
|
-
eval_model_override = req
|
|
1049
|
-
temp_header = req
|
|
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
|
|
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
|
-
|
|
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
|
|
1293
|
-
vision_model_override = request.vision_model or req
|
|
1294
|
-
eval_model_override = req
|
|
1295
|
-
temp_header = req
|
|
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
|
|
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
|
-
|
|
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.
|