alpha-engine-lib 0.40.0__tar.gz → 0.41.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/PKG-INFO +1 -1
  2. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/pyproject.toml +1 -1
  3. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/__init__.py +1 -1
  4. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/anthropic_payload.py +92 -0
  5. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/cost.py +31 -6
  6. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib.egg-info/PKG-INFO +1 -1
  7. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_anthropic_payload.py +117 -0
  8. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_cost.py +41 -0
  9. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/README.md +0 -0
  10. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/setup.cfg +0 -0
  11. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/agent_schemas.py +0 -0
  12. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/alerts.py +0 -0
  13. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/arcticdb.py +0 -0
  14. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/artifact_freshness.py +0 -0
  15. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/collector_results.py +0 -0
  16. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/dates.py +0 -0
  17. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/decision_capture.py +0 -0
  18. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/ec2_spot.py +0 -0
  19. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/email_sender.py +0 -0
  20. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/eval_artifacts.py +0 -0
  21. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/locks.py +0 -0
  22. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/logging.py +0 -0
  23. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/model_pricing.yaml +0 -0
  24. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/pillars.py +0 -0
  25. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/pipeline_status/__init__.py +0 -0
  26. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/pipeline_status/read.py +0 -0
  27. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/pipeline_status/registry.py +0 -0
  28. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/pipeline_status/templates.py +0 -0
  29. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/preflight.py +0 -0
  30. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/rag/__init__.py +0 -0
  31. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/rag/db.py +0 -0
  32. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/rag/embeddings.py +0 -0
  33. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/rag/migrations/0001_content_tsv.sql +0 -0
  34. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/rag/rerank.py +0 -0
  35. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/rag/retrieval.py +0 -0
  36. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/rag/schema.sql +0 -0
  37. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/reconcile.py +0 -0
  38. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/secrets.py +0 -0
  39. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/sources/__init__.py +0 -0
  40. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/sources/protocols.py +0 -0
  41. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/ssm_dispatcher.py +0 -0
  42. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/ssm_log_capture.py +0 -0
  43. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/telegram.py +0 -0
  44. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/trading_calendar.py +0 -0
  45. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/transparency.py +0 -0
  46. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/transparency_inventory.yaml +0 -0
  47. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib/universe.py +0 -0
  48. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib.egg-info/SOURCES.txt +0 -0
  49. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib.egg-info/dependency_links.txt +0 -0
  50. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib.egg-info/requires.txt +0 -0
  51. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/src/alpha_engine_lib.egg-info/top_level.txt +0 -0
  52. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_agent_schemas.py +0 -0
  53. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_alerts.py +0 -0
  54. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_arcticdb.py +0 -0
  55. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_artifact_freshness.py +0 -0
  56. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_collector_results.py +0 -0
  57. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_dates.py +0 -0
  58. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_decision_capture.py +0 -0
  59. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_ec2_spot.py +0 -0
  60. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_email_sender.py +0 -0
  61. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_eval_artifacts.py +0 -0
  62. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_locks.py +0 -0
  63. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_logging.py +0 -0
  64. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_pillars.py +0 -0
  65. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_pipeline_status_read.py +0 -0
  66. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_pipeline_status_registry.py +0 -0
  67. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_pipeline_status_templates.py +0 -0
  68. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_preflight.py +0 -0
  69. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_rag.py +0 -0
  70. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_rag_rerank.py +0 -0
  71. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_rag_retrieval_hybrid.py +0 -0
  72. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_reconcile.py +0 -0
  73. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_secrets.py +0 -0
  74. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_sources_protocols.py +0 -0
  75. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_ssm_dispatcher.py +0 -0
  76. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_ssm_log_capture.py +0 -0
  77. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_telegram.py +0 -0
  78. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_trading_calendar.py +0 -0
  79. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_transparency.py +0 -0
  80. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_universe.py +0 -0
  81. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_version_bump_workflow.py +0 -0
  82. {alpha_engine_lib-0.40.0 → alpha_engine_lib-0.41.0}/tests/test_version_pin.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpha-engine-lib
3
- Version: 0.40.0
3
+ Version: 0.41.0
4
4
  Summary: Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, and S3-conditional-PUT writer locks. Full surface documented in README.
5
5
  Author: Brian McMahon
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpha-engine-lib"
7
- version = "0.40.0"
7
+ version = "0.41.0"
8
8
  description = "Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, and S3-conditional-PUT writer locks. Full surface documented in README."
9
9
  readme = "README.md"
10
10
  # EC2 still runs Python 3.9 on the always-on micro instance (boto3 drops
@@ -1,3 +1,3 @@
1
1
  """alpha-engine-lib — shared utilities for Alpha Engine modules."""
2
2
 
3
- __version__ = "0.40.0"
3
+ __version__ = "0.41.0"
@@ -215,3 +215,95 @@ def build_messages_payload(
215
215
 
216
216
  validate_payload(payload)
217
217
  return payload
218
+
219
+
220
+ def build_batches_request_params(
221
+ *,
222
+ custom_id: str,
223
+ model: str,
224
+ max_tokens: int,
225
+ user_content: str,
226
+ tools: list[dict] | None = None,
227
+ tool_choice: dict[str, Any] | None = None,
228
+ system_prompt: str | None = None,
229
+ cache_system: bool = False,
230
+ extra: dict[str, Any] | None = None,
231
+ ) -> dict[str, Any]:
232
+ """Construct one entry of the ``messages.batches.create`` ``requests`` array.
233
+
234
+ The Anthropic Batches API takes a list of ``{"custom_id", "params"}``
235
+ dicts, where each ``params`` value is a kwargs dict for an underlying
236
+ ``messages.create()`` call. This helper builds one such entry,
237
+ validating the embedded payload via :func:`validate_payload`.
238
+
239
+ Differs from :func:`build_messages_payload` along three axes the
240
+ judge-batch path requires:
241
+
242
+ 1. **Optional system prompt.** Synchronous callers nearly always have
243
+ a static system prompt (the lib default caches it); judge batches
244
+ inject the entire rubric into the user message and have no system
245
+ block. Pass ``system_prompt=None`` (the default) to emit no
246
+ system block at all.
247
+ 2. **No cache_control by default.** The Batches API discounts every
248
+ call 50% before prompt caching applies; the marginal value of
249
+ caching is small enough that the existing judge path opts out.
250
+ ``cache_system=False`` is the default for this reason; pass
251
+ ``cache_system=True`` explicitly if the system prompt is large
252
+ enough to benefit.
253
+ 3. **Explicit tool_choice.** Forced tool calls (
254
+ ``{"type": "tool", "name": ...}``) are the dominant Batches use
255
+ case (structured-output via a known schema). Pass ``tool_choice``
256
+ directly rather than smuggling through ``extra``.
257
+
258
+ All :func:`validate_payload` invariants run against the embedded
259
+ ``params`` — including the server-tool ⊥ assistant-prefill check —
260
+ so a future Batches caller that mixes ``web_search`` with a
261
+ prefill won't reach Anthropic's HTTP 400.
262
+
263
+ Args:
264
+ custom_id: Per-request identifier returned in the batch result.
265
+ Caller-owned; must be unique within a batch.
266
+ model: Anthropic model identifier (e.g. ``"claude-haiku-4-5"``).
267
+ max_tokens: ``max_tokens`` for the embedded call.
268
+ user_content: The user-message content (typically the full
269
+ rendered rubric / prompt body, since batch calls usually
270
+ omit the system block).
271
+ tools: Optional list of tool specs.
272
+ tool_choice: Optional tool-choice spec (e.g.
273
+ ``{"type": "tool", "name": "RubricEvalLLMOutput"}`` to force
274
+ structured output via a specific tool).
275
+ system_prompt: Optional system-prompt text. When ``None`` (the
276
+ default), no ``system`` block is emitted.
277
+ cache_system: When ``True``, attach ``cache_control: ephemeral``
278
+ to the system block. Default ``False`` because Batches
279
+ already discounts 50% and the marginal cache value is small.
280
+ Ignored when ``system_prompt is None``.
281
+ extra: Optional dict merged into ``params`` after construction
282
+ (e.g. ``metadata``, ``stop_sequences``). Validation runs
283
+ AFTER the merge.
284
+
285
+ Returns:
286
+ ``{"custom_id": custom_id, "params": <validated kwargs dict>}``,
287
+ ready to splat into ``messages.batches.create(requests=[...])``.
288
+
289
+ Raises :exc:`PayloadInvariantError` on a known-incompatible shape.
290
+ """
291
+ params: dict[str, Any] = {
292
+ "model": model,
293
+ "max_tokens": max_tokens,
294
+ "messages": [{"role": "user", "content": user_content}],
295
+ }
296
+ if system_prompt is not None:
297
+ system_block: dict[str, Any] = {"type": "text", "text": system_prompt}
298
+ if cache_system:
299
+ system_block["cache_control"] = {"type": "ephemeral"}
300
+ params["system"] = [system_block]
301
+ if tools:
302
+ params["tools"] = list(tools)
303
+ if tool_choice is not None:
304
+ params["tool_choice"] = tool_choice
305
+ if extra:
306
+ params.update(extra)
307
+
308
+ validate_payload(params)
309
+ return {"custom_id": custom_id, "params": params}
@@ -58,6 +58,7 @@ Workstream design: ``alpha-engine-config/private-docs/ROADMAP.md`` line ~1708
58
58
 
59
59
  from __future__ import annotations
60
60
 
61
+ import re
61
62
  from datetime import date, datetime, timezone
62
63
  from importlib import resources
63
64
  from pathlib import Path
@@ -68,6 +69,17 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
68
69
 
69
70
  from alpha_engine_lib.decision_capture import ModelMetadata
70
71
 
72
+ # Anthropic SDK model IDs come in two forms: the family alias
73
+ # (e.g. ``claude-haiku-4-5``) and the dated snapshot form
74
+ # (e.g. ``claude-haiku-4-5-20251001``). ``Message.model`` returns the dated
75
+ # form even when the caller requested the alias, but our pricing YAML is
76
+ # keyed on the alias so a new snapshot date doesn't require a card refresh.
77
+ _DATED_SNAPSHOT_SUFFIX_RE = re.compile(r"-\d{8}$")
78
+
79
+
80
+ def _strip_dated_snapshot_suffix(model_name: str) -> str:
81
+ return _DATED_SNAPSHOT_SUFFIX_RE.sub("", model_name)
82
+
71
83
  if TYPE_CHECKING:
72
84
  # Structural Protocol below describes the only attributes we touch on
73
85
  # an Anthropic SDK ``Message`` — kept here so that ``anthropic`` does
@@ -172,14 +184,27 @@ class PriceTable(BaseModel):
172
184
  component is used for lookup) or a ``date``. The returned card is
173
185
  the one whose ``effective_from`` is the latest among cards ≤ ``at``.
174
186
 
175
- Raises :exc:`PriceCardLookupError` if the model has no cards or
176
- every card's ``effective_from`` is later than ``at``.
187
+ Lookup tries the model name as-given first; on miss, retries with
188
+ any trailing ``-YYYYMMDD`` snapshot suffix stripped. This lets the
189
+ YAML stay keyed on family aliases (``claude-haiku-4-5``) while
190
+ accepting the dated form (``claude-haiku-4-5-20251001``) that the
191
+ Anthropic SDK returns in ``Message.model``.
192
+
193
+ Raises :exc:`PriceCardLookupError` if neither form matches.
177
194
  """
178
195
  query_date = at.date() if isinstance(at, datetime) else at
179
- candidates = [
180
- c for c in self.cards
181
- if c.model_name == model_name and c.effective_from <= query_date
182
- ]
196
+
197
+ def _candidates_for(name: str) -> list[PriceCard]:
198
+ return [
199
+ c for c in self.cards
200
+ if c.model_name == name and c.effective_from <= query_date
201
+ ]
202
+
203
+ candidates = _candidates_for(model_name)
204
+ if not candidates:
205
+ alias = _strip_dated_snapshot_suffix(model_name)
206
+ if alias != model_name:
207
+ candidates = _candidates_for(alias)
183
208
  if not candidates:
184
209
  raise PriceCardLookupError(
185
210
  f"No price card for model {model_name!r} active on {query_date}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpha-engine-lib
3
- Version: 0.40.0
3
+ Version: 0.41.0
4
4
  Summary: Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, and S3-conditional-PUT writer locks. Full surface documented in README.
5
5
  Author: Brian McMahon
6
6
  License: Proprietary
@@ -30,6 +30,7 @@ from alpha_engine_lib.anthropic_payload import (
30
30
  DEFAULT_WEB_SEARCH_MAX_USES,
31
31
  SERVER_TOOL_PREFIXES,
32
32
  PayloadInvariantError,
33
+ build_batches_request_params,
33
34
  build_messages_payload,
34
35
  build_web_search_tool,
35
36
  validate_payload,
@@ -271,3 +272,119 @@ def test_build_messages_payload_morning_signal_replication():
271
272
  assert payload["tools"][0]["max_uses"] == 20
272
273
  assert len(payload["messages"]) == 1 # no assistant prefill
273
274
  assert opener in payload["messages"][0]["content"]
275
+
276
+
277
+ # ── build_batches_request_params ─────────────────────────────────────────────
278
+
279
+
280
+ _FORCE_TOOL_CHOICE = {"type": "tool", "name": "RubricEvalLLMOutput"}
281
+
282
+
283
+ def _custom_tool_spec():
284
+ """A non-server-side tool — what the judge batch uses for structured output."""
285
+ return {
286
+ "name": "RubricEvalLLMOutput",
287
+ "description": "Emit the rubric eval payload as structured JSON.",
288
+ "input_schema": {
289
+ "type": "object",
290
+ "properties": {"score": {"type": "integer"}},
291
+ "required": ["score"],
292
+ },
293
+ }
294
+
295
+
296
+ def test_build_batches_request_params_judge_shape():
297
+ """Replicates the alpha-engine-research judge call shape: no system
298
+ prompt, custom tool, forced tool_choice, no caching. Locks the
299
+ minimal viable Batches request envelope the judge actually ships."""
300
+ req = build_batches_request_params(
301
+ custom_id="judge-abc-123",
302
+ model="claude-haiku-4-5",
303
+ max_tokens=2048,
304
+ user_content="Rubric prompt body here…",
305
+ tools=[_custom_tool_spec()],
306
+ tool_choice=_FORCE_TOOL_CHOICE,
307
+ )
308
+ assert req["custom_id"] == "judge-abc-123"
309
+ params = req["params"]
310
+ assert params["model"] == "claude-haiku-4-5"
311
+ assert params["max_tokens"] == 2048
312
+ assert params["messages"] == [{"role": "user", "content": "Rubric prompt body here…"}]
313
+ assert params["tools"] == [_custom_tool_spec()]
314
+ assert params["tool_choice"] == _FORCE_TOOL_CHOICE
315
+ # No system prompt by default — judge inlines rubric into user content.
316
+ assert "system" not in params
317
+
318
+
319
+ def test_build_batches_request_params_with_system_prompt_no_cache_default():
320
+ """When a system prompt IS provided, it lands as a one-element system
321
+ array. Caching is OFF by default for batches per the docstring rationale."""
322
+ req = build_batches_request_params(
323
+ custom_id="x",
324
+ model="claude-sonnet-4-6",
325
+ max_tokens=256,
326
+ user_content="u",
327
+ system_prompt="You are a helpful assistant.",
328
+ )
329
+ sys_blocks = req["params"]["system"]
330
+ assert sys_blocks == [{"type": "text", "text": "You are a helpful assistant."}]
331
+ assert "cache_control" not in sys_blocks[0]
332
+
333
+
334
+ def test_build_batches_request_params_with_system_prompt_cache_opt_in():
335
+ """``cache_system=True`` attaches ephemeral cache_control (the
336
+ opt-in path for batches with large repeated system prompts)."""
337
+ req = build_batches_request_params(
338
+ custom_id="x",
339
+ model="claude-sonnet-4-6",
340
+ max_tokens=256,
341
+ user_content="u",
342
+ system_prompt="Large repeated system prompt.",
343
+ cache_system=True,
344
+ )
345
+ assert req["params"]["system"][0]["cache_control"] == {"type": "ephemeral"}
346
+
347
+
348
+ def test_build_batches_request_params_validates_server_tool_prefill_invariant():
349
+ """The Batches builder honors the same server-tool ⊥ assistant-prefill
350
+ invariant as the sync builder — caught via ``extra`` smuggling."""
351
+ with pytest.raises(PayloadInvariantError):
352
+ build_batches_request_params(
353
+ custom_id="x",
354
+ model="claude-sonnet-4-6",
355
+ max_tokens=256,
356
+ user_content="u",
357
+ tools=[build_web_search_tool()],
358
+ extra={
359
+ "messages": [
360
+ {"role": "user", "content": "hi"},
361
+ {"role": "assistant", "content": "Y"},
362
+ ]
363
+ },
364
+ )
365
+
366
+
367
+ def test_build_batches_request_params_no_system_no_tools_minimal():
368
+ """Minimal shape: only model + max_tokens + messages. Pins that
369
+ optional fields don't leak ``None`` keys into the payload."""
370
+ req = build_batches_request_params(
371
+ custom_id="x",
372
+ model="claude-haiku-4-5",
373
+ max_tokens=64,
374
+ user_content="ping",
375
+ )
376
+ params = req["params"]
377
+ assert set(params.keys()) == {"model", "max_tokens", "messages"}
378
+
379
+
380
+ def test_build_batches_request_params_extra_merges_into_params():
381
+ """``extra`` keys merge into ``params`` (e.g. metadata for batch-side
382
+ observability). Validation still runs."""
383
+ req = build_batches_request_params(
384
+ custom_id="x",
385
+ model="claude-haiku-4-5",
386
+ max_tokens=64,
387
+ user_content="u",
388
+ extra={"metadata": {"user_id": "judge-v3"}},
389
+ )
390
+ assert req["params"]["metadata"] == {"user_id": "judge-v3"}
@@ -155,6 +155,47 @@ class TestPriceTableLookup:
155
155
  self.table.get("haiku", date(2025, 12, 31))
156
156
 
157
157
 
158
+ class TestPriceTableLookupDatedSnapshotSuffix:
159
+ """Anthropic SDK returns ``Message.model`` in the dated snapshot form
160
+ (e.g. ``claude-haiku-4-5-20251001``) even when the caller requested
161
+ the alias; the YAML is keyed on the alias. Lookup must accept both.
162
+ """
163
+
164
+ def setup_method(self):
165
+ self.table = PriceTable(cards=[
166
+ _card("claude-haiku-4-5", 2026, 1, 1, in_p=1.0),
167
+ _card("claude-sonnet-4-6", 2026, 1, 1, in_p=3.0),
168
+ ])
169
+
170
+ def test_dated_suffix_falls_back_to_alias(self):
171
+ c = self.table.get("claude-haiku-4-5-20251001", date(2026, 5, 28))
172
+ assert c.input_per_1m == 1.0
173
+
174
+ def test_alias_lookup_unchanged(self):
175
+ c = self.table.get("claude-haiku-4-5", date(2026, 5, 28))
176
+ assert c.input_per_1m == 1.0
177
+
178
+ def test_exact_dated_match_wins_over_alias_fallback(self):
179
+ # If someone adds a dated card explicitly, it takes precedence.
180
+ table = PriceTable(cards=[
181
+ _card("claude-haiku-4-5", 2026, 1, 1, in_p=1.0),
182
+ _card("claude-haiku-4-5-20251001", 2026, 1, 1, in_p=9.99),
183
+ ])
184
+ c = table.get("claude-haiku-4-5-20251001", date(2026, 5, 28))
185
+ assert c.input_per_1m == 9.99
186
+
187
+ def test_unknown_alias_with_dated_suffix_still_hard_fails(self):
188
+ with pytest.raises(
189
+ PriceCardLookupError, match="claude-foo-9-9-20251001"
190
+ ):
191
+ self.table.get("claude-foo-9-9-20251001", date(2026, 5, 28))
192
+
193
+ def test_non_dated_suffix_is_not_stripped(self):
194
+ # Bare 8-digit substring without leading dash → no normalization.
195
+ with pytest.raises(PriceCardLookupError):
196
+ self.table.get("claude-haiku-4-5.20251001", date(2026, 5, 28))
197
+
198
+
158
199
  # ── compute_cost ──────────────────────────────────────────────────────────
159
200
 
160
201