hedit 0.7.11.dev5__tar.gz → 0.7.11.dev7__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.dev5/hedit.egg-info → hedit-0.7.11.dev7}/PKG-INFO +1 -1
  2. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/README.md +2 -0
  3. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7/hedit.egg-info}/PKG-INFO +1 -1
  4. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/pyproject.toml +1 -1
  5. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/api/main.py +21 -5
  6. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/cli/config.py +3 -2
  7. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/cli/local_executor.py +5 -2
  8. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/cli/main.py +1 -1
  9. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/utils/anthropic_llm.py +135 -6
  10. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/validation/hed_validator.py +9 -2
  11. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/version.py +1 -1
  12. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_anthropic_llm.py +144 -0
  13. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_api_endpoints.py +81 -0
  14. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_validation.py +55 -6
  15. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/LICENSE +0 -0
  16. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/PKG_README.md +0 -0
  17. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/hedit.egg-info/SOURCES.txt +0 -0
  18. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/hedit.egg-info/dependency_links.txt +0 -0
  19. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/hedit.egg-info/entry_points.txt +0 -0
  20. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/hedit.egg-info/requires.txt +0 -0
  21. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/hedit.egg-info/top_level.txt +0 -0
  22. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/setup.cfg +0 -0
  23. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/__init__.py +0 -0
  24. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/agents/__init__.py +0 -0
  25. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/agents/annotation_agent.py +0 -0
  26. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/agents/assessment_agent.py +0 -0
  27. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/agents/evaluation_agent.py +0 -0
  28. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/agents/feedback_summarizer.py +0 -0
  29. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/agents/feedback_triage_agent.py +0 -0
  30. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/agents/state.py +0 -0
  31. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/agents/validation_agent.py +0 -0
  32. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/agents/vision_agent.py +0 -0
  33. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/agents/workflow.py +0 -0
  34. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/api/__init__.py +0 -0
  35. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/api/models.py +0 -0
  36. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/api/security.py +0 -0
  37. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/cli/__init__.py +0 -0
  38. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/cli/api_executor.py +0 -0
  39. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/cli/client.py +0 -0
  40. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/cli/commands/__init__.py +0 -0
  41. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/cli/commands/lsp.py +0 -0
  42. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/cli/executor.py +0 -0
  43. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/cli/output.py +0 -0
  44. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/data/__init__.py +0 -0
  45. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/data/hed-docs/02_Terminology.md +0 -0
  46. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/data/hed-docs/HedAnnotationSemantics.md +0 -0
  47. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/data/hed-docs/manifest.json +0 -0
  48. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/lsp/__init__.py +0 -0
  49. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/lsp/client.py +0 -0
  50. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/lsp/daemon.py +0 -0
  51. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/lsp/protocol.py +0 -0
  52. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/scripts/__init__.py +0 -0
  53. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/scripts/process_feedback.py +0 -0
  54. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/telemetry/__init__.py +0 -0
  55. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/telemetry/collector.py +0 -0
  56. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/telemetry/schema.py +0 -0
  57. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/telemetry/storage.py +0 -0
  58. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/utils/__init__.py +0 -0
  59. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/utils/error_remediation.py +0 -0
  60. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/utils/github_client.py +0 -0
  61. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/utils/hed_comprehensive_guide.py +0 -0
  62. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/utils/hed_docs_loader.py +0 -0
  63. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/utils/image_processing.py +0 -0
  64. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/utils/json_schema_loader.py +0 -0
  65. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/utils/llm_usage.py +0 -0
  66. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/utils/schema_loader.py +0 -0
  67. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/validation/__init__.py +0 -0
  68. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/src/validation/hed_lsp.py +0 -0
  69. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_annotation_agent.py +0 -0
  70. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_cli_client.py +0 -0
  71. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_cli_config.py +0 -0
  72. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_cli_integration.py +0 -0
  73. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_cli_main.py +0 -0
  74. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_cli_usage_report.py +0 -0
  75. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_comprehensive_guide.py +0 -0
  76. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_error_remediation.py +0 -0
  77. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_feedback_integration.py +0 -0
  78. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_feedback_triage.py +0 -0
  79. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_fetch_hed_docs.py +0 -0
  80. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_github_client.py +0 -0
  81. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_hed_docs_loader.py +0 -0
  82. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_integration_anthropic.py +0 -0
  83. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_json_schema_loader.py +0 -0
  84. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_llm_usage.py +0 -0
  85. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_no_extend_propagation.py +0 -0
  86. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_schema_loader.py +0 -0
  87. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_security.py +0 -0
  88. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_state.py +0 -0
  89. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_telemetry.py +0 -0
  90. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/tests/test_validation_agent.py +0 -0
  91. {hedit-0.7.11.dev5 → hedit-0.7.11.dev7}/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.dev5
3
+ Version: 0.7.11.dev7
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
@@ -84,7 +84,9 @@ The agents work in feedback loops, automatically refining the annotation until i
84
84
 
85
85
  ## Documentation
86
86
 
87
+ - [Changelog](CHANGELOG.md) - What changed in each release
87
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
88
90
  - [HED Standard](https://hedtags.org) - Learn about HED annotations
89
91
  - [GitHub Issues](https://github.com/Annotation-Garden/HEDit/issues) - Report bugs or request features
90
92
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hedit
3
- Version: 0.7.11.dev5
3
+ Version: 0.7.11.dev7
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hedit"
7
- version = "0.7.11.dev5"
7
+ version = "0.7.11.dev7"
8
8
  description = "Multi-agent system for HED annotation generation and validation"
9
9
  readme = "PKG_README.md"
10
10
  requires-python = ">=3.12"
@@ -40,7 +40,12 @@ from src.api.models import (
40
40
  from src.api.security import api_key_auth, audit_logger
41
41
  from src.lsp import HedLspClient
42
42
  from src.telemetry import LocalFileStorage, TelemetryCollector, TelemetryEvent
43
- 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
+ )
44
49
  from src.utils.llm_usage import UsageLedger, process_ledger, usage_scope
45
50
  from src.utils.schema_loader import HedSchemaLoader
46
51
  from src.validation.hed_validator import HedPythonValidator
@@ -77,6 +82,11 @@ def _describe_llm_error(exc: Exception) -> tuple[int, str, str]:
77
82
  key gets a 401 pointing at their key, not a generic 500. Order matters --
78
83
  AnthropicContextOverflowError subclasses BadRequestError, and the timeout
79
84
  and rate-limit types must be checked before their parent classes.
85
+
86
+ Every message here is a fixed string. Exception text never reaches the
87
+ client, because a provider error can carry request details and the web app
88
+ renders the message as HTML. Callers log the exception, so the provider's
89
+ own wording stays available in the server log for whoever debugs it.
80
90
  """
81
91
  if isinstance(exc, APITimeoutError):
82
92
  return 504, "timeout", "LLM request timed out. Try again or use a faster model."
@@ -93,7 +103,11 @@ def _describe_llm_error(exc: Exception) -> tuple[int, str, str]:
93
103
  if isinstance(exc, AnthropicContextOverflowError):
94
104
  return 413, "context_overflow", "The input is too long for this model. Try shortening it."
95
105
  if isinstance(exc, anthropic.BadRequestError):
96
- return 400, "bad_request", f"The LLM rejected the request: {str(exc)[:200]}"
106
+ return (
107
+ 400,
108
+ "bad_request",
109
+ "The LLM rejected the request. Check the model and request parameters, then try again.",
110
+ )
97
111
  if isinstance(exc, anthropic.APIConnectionError):
98
112
  return 502, "upstream_unreachable", "Could not reach the LLM service. Please try again."
99
113
  return 500, "internal", "An error occurred during annotation processing."
@@ -200,13 +214,15 @@ def create_anthropic_workflow(
200
214
  normalize_model(actual_eval_model)
201
215
 
202
216
  # Create LLMs.
203
- # The annotation LLM keeps reasoning enabled that's the model
204
- # doing the actual HED tag synthesis where extended thinking
205
- # measurably improves first-attempt quality.
217
+ # Annotation thinks: measured over the benchmark descriptions, a 2048-token
218
+ # budget took first-attempt validity from 5/15 to 13/15 and cut total LLM
219
+ # calls by a third, for 24% more cost and about twice the latency. See
220
+ # annotation_thinking() and docs/prompt-caching.md.
206
221
  annotation_llm = create_anthropic_llm(
207
222
  model=actual_annotation_model,
208
223
  api_key=api_key,
209
224
  temperature=actual_temperature,
225
+ thinking=annotation_thinking(actual_annotation_model),
210
226
  role="annotation",
211
227
  )
212
228
  # Evaluation / assessment / feedback / keyword extraction are short
@@ -34,8 +34,9 @@ DEFAULT_API_URL = "https://api.annotation.garden/hedit"
34
34
  DEFAULT_DEV_API_URL = "https://api.annotation.garden/hedit-dev"
35
35
 
36
36
  # Default models (Anthropic Claude only since the 2026-08-18 migration).
37
- # Annotation model: Claude Haiku 4.5 (fast, near-frontier quality).
38
- # Claude Sonnet 5 ("claude-sonnet-5") is offered for highest quality.
37
+ # Annotation model: Claude Haiku 4.5, which runs with extended thinking and
38
+ # matched Sonnet 5's first-attempt validity for 2.3x less cost (docs/reasoning.md).
39
+ # Claude Sonnet 5 ("claude-sonnet-5") stays selectable for comparison.
39
40
  DEFAULT_MODEL = "claude-haiku-4-5"
40
41
 
41
42
  # Evaluation judge stays on Haiku regardless of the annotation model.
@@ -150,13 +150,16 @@ class LocalExecutionBackend(ExecutionBackend):
150
150
  self._ensure_api_key()
151
151
 
152
152
  from src.agents.workflow import HedAnnotationWorkflow
153
- from src.utils.anthropic_llm import create_anthropic_llm
153
+ from src.utils.anthropic_llm import annotation_thinking, create_anthropic_llm
154
154
 
155
- # Annotation LLM keeps reasoning enabled (real HED tag work).
155
+ # Annotation thinks; see annotation_thinking() for the measurement
156
+ # behind the budget and HEDIT_ANNOTATION_THINKING_BUDGET to change
157
+ # or disable it.
156
158
  annotation_llm = create_anthropic_llm(
157
159
  model=self._model,
158
160
  api_key=self._api_key,
159
161
  temperature=self._temperature,
162
+ thinking=annotation_thinking(self._model),
160
163
  role="annotation",
161
164
  )
162
165
 
@@ -104,7 +104,7 @@ ModelOption = Annotated[
104
104
  typer.Option(
105
105
  "--model",
106
106
  "-m",
107
- help="Model to use: claude-haiku-4-5 (default) or claude-sonnet-5",
107
+ help="Model to use: claude-haiku-4-5 (default, recommended) or claude-sonnet-5",
108
108
  ),
109
109
  ]
110
110
 
@@ -41,8 +41,8 @@ DEFAULT_MODEL = "claude-haiku-4-5"
41
41
  # Models offered to users (web UI dropdown / CLI). Opus is deliberately
42
42
  # not offered.
43
43
  ALLOWED_MODELS = {
44
- "claude-haiku-4-5": "Claude Haiku 4.5 (fast, default)",
45
- "claude-sonnet-5": "Claude Sonnet 5 (highest quality)",
44
+ "claude-haiku-4-5": "Claude Haiku 4.5 (default, recommended)",
45
+ "claude-sonnet-5": "Claude Sonnet 5 (larger, 2.3x the cost)",
46
46
  }
47
47
 
48
48
  # Legacy identifiers from older clients, saved CLI configs, and cached
@@ -78,6 +78,20 @@ _ADAPTIVE_THINKING_MODELS = {"claude-sonnet-5"}
78
78
  _ALWAYS_THINKING_MODELS: set[str] = set()
79
79
  LOWEST_REASONING_EFFORT = "low"
80
80
 
81
+ # Smallest thinking budget the API accepts on budget-style models.
82
+ MIN_THINKING_BUDGET_TOKENS = 1024
83
+
84
+ # Thinking budget for the annotation role on budget-style models. Measured
85
+ # over the 15 benchmark descriptions (2026-08-20, Haiku 4.5): first-attempt
86
+ # validity went 5/15 without thinking to 11/15 at 1024 tokens and 13/15 at
87
+ # 2048, average attempts 1.87 -> 1.13, and total LLM calls 71 -> 49. Cost per
88
+ # request rose 24% ($0.0092 -> $0.0114) while 2048 came out cheaper than 1024
89
+ # ($0.0129), because the larger budget removed more refinement rounds than it
90
+ # added in thinking tokens. Latency roughly doubled (10.3s -> 20.9s), which is
91
+ # the real price; HEDIT_ANNOTATION_THINKING_BUDGET=0 turns it off for
92
+ # latency-sensitive deployments.
93
+ DEFAULT_ANNOTATION_THINKING_BUDGET = 2048
94
+
81
95
  # Models that still accept sampling parameters. Sonnet 5 rejects
82
96
  # `temperature` with a 400 (sampling params are removed on Claude 5 models).
83
97
  _SAMPLING_MODELS = {"claude-haiku-4-5"}
@@ -114,6 +128,101 @@ def normalize_model(model: str | None) -> str:
114
128
  return resolved
115
129
 
116
130
 
131
+ def _validate_thinking(thinking: dict[str, Any], model: str, max_tokens: int) -> None:
132
+ """Check a thinking configuration against what the model accepts.
133
+
134
+ The API enforces different shapes per generation, and a mismatch is a
135
+ 400 at request time: Sonnet 5 rejects thinking.type "enabled" ("Use
136
+ thinking.type adaptive"), while Haiku 4.5 has no adaptive mode and needs
137
+ an explicit token budget.
138
+
139
+ Args:
140
+ thinking: Thinking configuration to check
141
+ model: Resolved first-party model id
142
+ max_tokens: Maximum tokens for the request
143
+
144
+ Raises:
145
+ ValueError: If the configuration is not valid for this model
146
+ """
147
+ kind = thinking.get("type")
148
+
149
+ if model in _ADAPTIVE_THINKING_MODELS:
150
+ if kind not in ("adaptive", "disabled"):
151
+ raise ValueError(
152
+ f"{model} accepts thinking type 'adaptive' or 'disabled', not {kind!r}; "
153
+ "budget_tokens was removed on this model generation"
154
+ )
155
+ return
156
+
157
+ if kind == "disabled":
158
+ # Accepted (verified against the endpoint) and redundant on these
159
+ # models, but it lets a caller express "off" the same way for every
160
+ # model instead of special-casing.
161
+ return
162
+
163
+ if kind != "enabled":
164
+ raise ValueError(
165
+ f"{model} has no adaptive thinking mode; enable it with "
166
+ '{"type": "enabled", "budget_tokens": N}, disable it with '
167
+ '{"type": "disabled"}, or leave thinking unset'
168
+ )
169
+
170
+ budget = thinking.get("budget_tokens")
171
+ if not isinstance(budget, int) or budget < MIN_THINKING_BUDGET_TOKENS:
172
+ raise ValueError(
173
+ f"budget_tokens must be an integer of at least "
174
+ f"{MIN_THINKING_BUDGET_TOKENS}, got {budget!r}"
175
+ )
176
+ if budget >= max_tokens:
177
+ raise ValueError(
178
+ f"budget_tokens ({budget}) must be below max_tokens ({max_tokens}); "
179
+ "thinking tokens are drawn from the same budget as the response"
180
+ )
181
+
182
+
183
+ def annotation_thinking(model: str | None = None) -> dict[str, Any] | None:
184
+ """Thinking configuration for the annotation role.
185
+
186
+ Reasoning measurably improves first-attempt validity on the annotation
187
+ agent, which is worth paying for because every failed attempt costs
188
+ another full annotate/validate/evaluate round. The support roles are
189
+ unaffected: reasoning there added latency without quality (#150).
190
+
191
+ Set HEDIT_ANNOTATION_THINKING_BUDGET to a token count to change the
192
+ budget, or to 0 (or "off") to disable thinking entirely.
193
+
194
+ Args:
195
+ model: Model the annotation LLM will use (default model if None)
196
+
197
+ Returns:
198
+ A thinking configuration for the model, or None when disabled
199
+
200
+ Raises:
201
+ ValueError: If the environment override is not an integer or "off"
202
+ """
203
+ resolved_model = normalize_model(model)
204
+
205
+ raw = os.getenv("HEDIT_ANNOTATION_THINKING_BUDGET")
206
+ if raw is not None and raw.strip().lower() in ("0", "off", "false", "none"):
207
+ return None
208
+
209
+ if resolved_model in _ADAPTIVE_THINKING_MODELS:
210
+ # Adaptive is the only on-mode on this generation; the model decides
211
+ # how much to think, so the budget value does not apply.
212
+ return {"type": "adaptive"}
213
+
214
+ budget = DEFAULT_ANNOTATION_THINKING_BUDGET
215
+ if raw is not None:
216
+ try:
217
+ budget = int(raw)
218
+ except ValueError as exc:
219
+ raise ValueError(
220
+ f"HEDIT_ANNOTATION_THINKING_BUDGET must be an integer or 'off', got {raw!r}"
221
+ ) from exc
222
+
223
+ return {"type": "enabled", "budget_tokens": budget}
224
+
225
+
117
226
  def create_anthropic_llm(
118
227
  model: str | None = None,
119
228
  api_key: str | None = None,
@@ -124,6 +233,7 @@ def create_anthropic_llm(
124
233
  timeout: float = 60.0,
125
234
  role: str = "unspecified",
126
235
  cache_ttl: str | None = None,
236
+ thinking: dict[str, Any] | None = None,
127
237
  ) -> BaseChatModel:
128
238
  """Create a Claude LLM instance with prompt caching.
129
239
 
@@ -151,12 +261,21 @@ def create_anthropic_llm(
151
261
  token and cache usage; has no effect on the request itself.
152
262
  cache_ttl: Prompt-cache lifetime, "5m" (default) or "1h". Falls back
153
263
  to the HEDIT_PROMPT_CACHE_TTL environment variable when None.
264
+ thinking: Explicit extended-thinking configuration, overriding
265
+ disable_reasoning. The accepted shape depends on the model, and
266
+ the API is strict about it: Sonnet 5 takes
267
+ {"type": "adaptive"} or {"type": "disabled"} and rejects
268
+ "enabled"; Haiku 4.5 takes
269
+ {"type": "enabled", "budget_tokens": N} with N at least 1024 and
270
+ below max_tokens. Enabling thinking also drops `temperature`,
271
+ since the API only allows temperature 1 alongside thinking.
154
272
 
155
273
  Returns:
156
274
  LLM instance configured for the Claude Messages API
157
275
 
158
276
  Raises:
159
- ValueError: If the model is not offered or the TTL is not supported
277
+ ValueError: If the model is not offered, the TTL is not supported, or
278
+ the thinking configuration is not valid for the model
160
279
  RuntimeError: If server mode is used without ANTHROPIC_API_KEY set
161
280
  """
162
281
  from langchain_anthropic import ChatAnthropic
@@ -188,10 +307,20 @@ def create_anthropic_llm(
188
307
  if workspace_id:
189
308
  kwargs["default_headers"] = {"anthropic-workspace-id": workspace_id}
190
309
 
191
- if resolved_model in _SAMPLING_MODELS:
310
+ resolved_max_tokens = max_tokens or 8000
311
+
312
+ if thinking is not None:
313
+ _validate_thinking(thinking, resolved_model, resolved_max_tokens)
314
+ kwargs["thinking"] = thinking
315
+
316
+ thinking_on = thinking is not None and thinking.get("type") != "disabled"
317
+
318
+ # The API rejects any temperature but 1 when thinking is enabled, so the
319
+ # sampling setting is dropped rather than silently causing a 400.
320
+ if resolved_model in _SAMPLING_MODELS and not thinking_on:
192
321
  kwargs["temperature"] = temperature
193
322
 
194
- if disable_reasoning:
323
+ if disable_reasoning and thinking is None:
195
324
  if resolved_model in _ADAPTIVE_THINKING_MODELS:
196
325
  kwargs["thinking"] = {"type": "disabled"}
197
326
  elif resolved_model in _ALWAYS_THINKING_MODELS:
@@ -199,7 +328,7 @@ def create_anthropic_llm(
199
328
 
200
329
  llm = ChatAnthropic(
201
330
  model=resolved_model,
202
- max_tokens=max_tokens or 8000,
331
+ max_tokens=resolved_max_tokens,
203
332
  timeout=timeout,
204
333
  **kwargs,
205
334
  )
@@ -16,7 +16,7 @@ from pathlib import Path
16
16
  from typing import Literal
17
17
 
18
18
  from hed import HedString
19
- from hed.errors import get_printable_issue_string
19
+ from hed.errors import ErrorSeverity, get_printable_issue_string
20
20
  from hed.schema import HedSchema, load_schema_version
21
21
  from hed.validator import HedValidator
22
22
 
@@ -167,8 +167,15 @@ class HedPythonValidator:
167
167
  # Process issues
168
168
  for issue in issues:
169
169
  issue_str = get_printable_issue_string([issue])
170
+ # hedtools reports severity as an ErrorSeverity enum
171
+ # (ERROR=1, WARNING=10), never the string "error". Comparing
172
+ # it to "error" was always False, so every error was filed as
173
+ # a warning and is_valid was always True: "NotARealTag/Foo"
174
+ # validated clean and the workflow's refinement loop could
175
+ # never fire (#161). Anything not explicitly a warning counts
176
+ # as an error, so an unrecognized severity fails closed.
170
177
  severity: Literal["error", "warning"] = (
171
- "error" if issue["severity"] == "error" else "warning"
178
+ "warning" if issue.get("severity") == ErrorSeverity.WARNING else "error"
172
179
  )
173
180
 
174
181
  validation_issue = ValidationIssue(
@@ -1,6 +1,6 @@
1
1
  """Version information for HEDit."""
2
2
 
3
- __version__ = "0.7.11.dev5"
3
+ __version__ = "0.7.11.dev7"
4
4
  __version_info__ = (0, 7, 11, "dev")
5
5
 
6
6
 
@@ -7,8 +7,10 @@ from langchain_core.outputs import ChatGeneration, ChatResult
7
7
  from src.utils import anthropic_llm
8
8
  from src.utils.anthropic_llm import (
9
9
  ALLOWED_MODELS,
10
+ DEFAULT_ANNOTATION_THINKING_BUDGET,
10
11
  DEFAULT_MODEL,
11
12
  CachingLLMWrapper,
13
+ annotation_thinking,
12
14
  create_anthropic_llm,
13
15
  normalize_model,
14
16
  )
@@ -305,3 +307,145 @@ class TestCacheTtl:
305
307
  monkeypatch.setenv("HEDIT_PROMPT_CACHE_TTL", "forever")
306
308
  with pytest.raises(ValueError, match="Unsupported prompt cache TTL"):
307
309
  create_anthropic_llm()
310
+
311
+
312
+ class TestThinkingConfiguration:
313
+ """Tests for the explicit thinking knob.
314
+
315
+ The shapes asserted here are the ones the API actually enforces, checked
316
+ against the live endpoint: Haiku 4.5 needs an explicit budget and refuses
317
+ any temperature but 1 alongside thinking; Sonnet 5 rejects
318
+ thinking.type "enabled" and wants "adaptive".
319
+ """
320
+
321
+ @pytest.fixture(autouse=True)
322
+ def server_key(self, monkeypatch):
323
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "server-key")
324
+
325
+ def test_haiku_takes_a_budget(self):
326
+ llm = create_anthropic_llm(
327
+ model="claude-haiku-4-5",
328
+ thinking={"type": "enabled", "budget_tokens": 1024},
329
+ enable_caching=False,
330
+ )
331
+ assert llm.thinking == {"type": "enabled", "budget_tokens": 1024}
332
+
333
+ def test_temperature_is_dropped_when_thinking_is_on(self):
334
+ """The API allows only temperature 1 with thinking, so it is omitted."""
335
+ llm = create_anthropic_llm(
336
+ model="claude-haiku-4-5",
337
+ temperature=0.1,
338
+ thinking={"type": "enabled", "budget_tokens": 1024},
339
+ enable_caching=False,
340
+ )
341
+ assert llm.temperature is None
342
+
343
+ def test_temperature_survives_thinking_disabled(self):
344
+ llm = create_anthropic_llm(
345
+ model="claude-haiku-4-5",
346
+ temperature=0.1,
347
+ thinking={"type": "disabled"},
348
+ enable_caching=False,
349
+ )
350
+ assert llm.temperature == 0.1
351
+
352
+ def test_sonnet_takes_adaptive(self):
353
+ llm = create_anthropic_llm(
354
+ model="claude-sonnet-5", thinking={"type": "adaptive"}, enable_caching=False
355
+ )
356
+ assert llm.thinking == {"type": "adaptive"}
357
+
358
+ def test_sonnet_rejects_budget_style_thinking(self):
359
+ with pytest.raises(ValueError, match="adaptive"):
360
+ create_anthropic_llm(
361
+ model="claude-sonnet-5",
362
+ thinking={"type": "enabled", "budget_tokens": 1024},
363
+ enable_caching=False,
364
+ )
365
+
366
+ def test_haiku_rejects_adaptive(self):
367
+ with pytest.raises(ValueError, match="no adaptive thinking mode"):
368
+ create_anthropic_llm(
369
+ model="claude-haiku-4-5", thinking={"type": "adaptive"}, enable_caching=False
370
+ )
371
+
372
+ def test_budget_below_minimum_rejected(self):
373
+ with pytest.raises(ValueError, match="at least 1024"):
374
+ create_anthropic_llm(
375
+ model="claude-haiku-4-5",
376
+ thinking={"type": "enabled", "budget_tokens": 512},
377
+ enable_caching=False,
378
+ )
379
+
380
+ def test_budget_must_fit_under_max_tokens(self):
381
+ with pytest.raises(ValueError, match="must be below max_tokens"):
382
+ create_anthropic_llm(
383
+ model="claude-haiku-4-5",
384
+ max_tokens=2000,
385
+ thinking={"type": "enabled", "budget_tokens": 2000},
386
+ enable_caching=False,
387
+ )
388
+
389
+ def test_explicit_thinking_overrides_disable_reasoning(self):
390
+ llm = create_anthropic_llm(
391
+ model="claude-sonnet-5",
392
+ disable_reasoning=True,
393
+ thinking={"type": "adaptive"},
394
+ enable_caching=False,
395
+ )
396
+ assert llm.thinking == {"type": "adaptive"}
397
+
398
+
399
+ class TestAnnotationThinking:
400
+ """Tests for the annotation role's thinking policy."""
401
+
402
+ @pytest.fixture(autouse=True)
403
+ def clean_env(self, monkeypatch):
404
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "server-key")
405
+ monkeypatch.delenv("HEDIT_ANNOTATION_THINKING_BUDGET", raising=False)
406
+
407
+ def test_default_budget_on_haiku(self):
408
+ assert annotation_thinking("claude-haiku-4-5") == {
409
+ "type": "enabled",
410
+ "budget_tokens": DEFAULT_ANNOTATION_THINKING_BUDGET,
411
+ }
412
+
413
+ def test_default_model_gets_a_budget(self):
414
+ assert annotation_thinking()["type"] == "enabled"
415
+
416
+ def test_adaptive_model_ignores_the_budget(self):
417
+ """Sonnet 5 decides its own depth; budget_tokens is rejected there."""
418
+ assert annotation_thinking("claude-sonnet-5") == {"type": "adaptive"}
419
+
420
+ def test_env_override_sets_the_budget(self, monkeypatch):
421
+ monkeypatch.setenv("HEDIT_ANNOTATION_THINKING_BUDGET", "4096")
422
+ assert annotation_thinking("claude-haiku-4-5")["budget_tokens"] == 4096
423
+
424
+ def test_env_can_disable_thinking(self, monkeypatch):
425
+ for value in ("0", "off", "false", "none", "OFF"):
426
+ monkeypatch.setenv("HEDIT_ANNOTATION_THINKING_BUDGET", value)
427
+ assert annotation_thinking("claude-haiku-4-5") is None
428
+ assert annotation_thinking("claude-sonnet-5") is None
429
+
430
+ def test_bad_env_value_is_rejected(self, monkeypatch):
431
+ monkeypatch.setenv("HEDIT_ANNOTATION_THINKING_BUDGET", "lots")
432
+ with pytest.raises(ValueError, match="must be an integer or 'off'"):
433
+ annotation_thinking("claude-haiku-4-5")
434
+
435
+ def test_legacy_model_id_is_normalized(self):
436
+ assert annotation_thinking("anthropic/claude-haiku-4.5")["type"] == "enabled"
437
+
438
+ def test_policy_produces_a_usable_llm(self):
439
+ """The default policy must satisfy the factory's own validation."""
440
+ llm = create_anthropic_llm(
441
+ model="claude-haiku-4-5",
442
+ thinking=annotation_thinking("claude-haiku-4-5"),
443
+ enable_caching=False,
444
+ )
445
+ assert llm.thinking["budget_tokens"] == DEFAULT_ANNOTATION_THINKING_BUDGET
446
+ # Thinking forces temperature off, which the API requires.
447
+ assert llm.temperature is None
448
+
449
+ def test_budget_fits_under_the_default_max_tokens(self):
450
+ """A budget above max_tokens would 400 at request time."""
451
+ assert DEFAULT_ANNOTATION_THINKING_BUDGET < 8000
@@ -1469,3 +1469,84 @@ class TestMetricsEndpoint:
1469
1469
  )
1470
1470
  assert response.status_code == 403
1471
1471
  assert "server API key" in response.json()["detail"]
1472
+
1473
+
1474
+ class TestLLMErrorMessages:
1475
+ """Error responses classify the failure without quoting the provider.
1476
+
1477
+ CodeQL flagged the streaming error path (py/stack-trace-exposure) because
1478
+ exception text reached the client. The web app renders these messages as
1479
+ HTML, and a provider error can echo request details, so every branch has
1480
+ to return a fixed string.
1481
+ """
1482
+
1483
+ @staticmethod
1484
+ def _response(status: int):
1485
+ import httpx
1486
+
1487
+ request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
1488
+ return httpx.Response(status, request=request)
1489
+
1490
+ def test_bad_request_does_not_quote_the_provider(self):
1491
+ import anthropic
1492
+
1493
+ from src.api.main import _describe_llm_error
1494
+
1495
+ detail = (
1496
+ "Error code: 400 - thinking.type.enabled is not supported for this model; "
1497
+ "workspace wrkspc_01Rayuq1JvjaMYusRvUsn6vk"
1498
+ )
1499
+ exc = anthropic.BadRequestError(detail, response=self._response(400), body=None)
1500
+
1501
+ status, error_type, message = _describe_llm_error(exc)
1502
+
1503
+ assert status == 400
1504
+ assert error_type == "bad_request"
1505
+ assert "wrkspc_01Rayuq1JvjaMYusRvUsn6vk" not in message
1506
+ assert "thinking.type.enabled" not in message
1507
+ assert "Error code" not in message
1508
+
1509
+ def test_context_overflow_still_precedes_bad_request(self):
1510
+ from langchain_anthropic.chat_models import AnthropicContextOverflowError
1511
+
1512
+ from src.api.main import _describe_llm_error
1513
+
1514
+ exc = AnthropicContextOverflowError(
1515
+ "prompt is too long: 250000 tokens > 200000 maximum",
1516
+ response=self._response(400),
1517
+ body=None,
1518
+ )
1519
+
1520
+ status, error_type, _message = _describe_llm_error(exc)
1521
+
1522
+ assert status == 413
1523
+ assert error_type == "context_overflow"
1524
+
1525
+ def test_auth_failure_points_at_the_key(self):
1526
+ import anthropic
1527
+
1528
+ from src.api.main import _describe_llm_error
1529
+
1530
+ exc = anthropic.AuthenticationError(
1531
+ "invalid x-api-key sk-ant-api03-secretmaterial",
1532
+ response=self._response(401),
1533
+ body=None,
1534
+ )
1535
+
1536
+ status, error_type, message = _describe_llm_error(exc)
1537
+
1538
+ assert status == 401
1539
+ assert error_type == "auth"
1540
+ assert "sk-ant-api03-secretmaterial" not in message
1541
+
1542
+ def test_unknown_failure_is_generic(self):
1543
+ from src.api.main import _describe_llm_error
1544
+
1545
+ status, error_type, message = _describe_llm_error(
1546
+ RuntimeError("Traceback (most recent call last): File /srv/hedit/main.py")
1547
+ )
1548
+
1549
+ assert status == 500
1550
+ assert error_type == "internal"
1551
+ assert "Traceback" not in message
1552
+ assert "/srv/hedit" not in message
@@ -118,6 +118,57 @@ class TestHedPythonValidator:
118
118
  assert isinstance(result.errors, list)
119
119
 
120
120
 
121
+ class TestSeverityMapping:
122
+ """Tests that hedtools severities map to the right bucket.
123
+
124
+ hedtools reports severity as an ErrorSeverity enum (ERROR=1,
125
+ WARNING=10). Comparing it to the string "error" is always False, which
126
+ filed every error as a warning and left is_valid True for any input --
127
+ "NotARealTag/Foo" validated clean and the workflow's refinement loop
128
+ could never fire (#161).
129
+ """
130
+
131
+ def test_unknown_tag_is_an_error(self, validator):
132
+ result = validator.validate("Sensory-event, NotARealTag/Foo")
133
+
134
+ assert result.is_valid is False
135
+ assert [issue.code for issue in result.errors] == ["TAG_INVALID"]
136
+ assert result.warnings == []
137
+
138
+ def test_non_base_tag_is_an_error(self, validator):
139
+ """A plausible-looking tag that is not in the schema still fails."""
140
+ result = validator.validate("Agent-action, (Press, Button)")
141
+
142
+ assert result.is_valid is False
143
+ assert any(issue.code == "TAG_INVALID" for issue in result.errors)
144
+
145
+ def test_repeated_tag_is_an_error(self, validator):
146
+ result = validator.validate("Sensory-event, Sensory-event")
147
+
148
+ assert result.is_valid is False
149
+ assert any(issue.code == "TAG_EXPRESSION_REPEATED" for issue in result.errors)
150
+
151
+ def test_tag_extension_is_a_warning(self, validator):
152
+ """Extensions are allowed, so they must not invalidate the string."""
153
+ result = validator.validate("Sensory-event, Animal/Dog")
154
+
155
+ assert result.is_valid is True
156
+ assert result.errors == []
157
+ assert [issue.code for issue in result.warnings] == ["TAG_EXTENDED"]
158
+
159
+ def test_clean_string_has_no_issues(self, validator):
160
+ result = validator.validate("Sensory-event, Visual-presentation")
161
+
162
+ assert result.is_valid is True
163
+ assert result.errors == []
164
+ assert result.warnings == []
165
+
166
+ def test_errors_carry_their_level(self, validator):
167
+ result = validator.validate("Sensory-event, NotARealTag/Foo")
168
+
169
+ assert all(issue.level == "error" for issue in result.errors)
170
+
171
+
121
172
  class TestHedJavaScriptValidator:
122
173
  """Tests for HedJavaScriptValidator class."""
123
174
 
@@ -189,14 +240,12 @@ def test_validate_valid_string(validator):
189
240
 
190
241
 
191
242
  def test_validate_invalid_tag(validator):
192
- """Test validation of invalid tag.
193
-
194
- Note: HED 8.3.0+ reports invalid tags as warnings, not errors.
195
- """
243
+ """An unknown tag is an error, so the string is invalid."""
196
244
  result = validator.validate("Invalid-nonexistent-tag")
197
245
 
198
- # Invalid tags may be reported as warnings in newer HED versions
199
- assert result.is_valid is False or len(result.warnings) > 0
246
+ assert result.is_valid is False
247
+ assert len(result.errors) >= 1
248
+ assert result.errors[0].code == "TAG_INVALID"
200
249
 
201
250
 
202
251
  def test_validate_with_grouping(validator):
File without changes
File without changes
File without changes
File without changes