feat(run): S10 del 1 — run-lag: §9-citations, artefakt-persistens, SDK-klient
TDD offline (RØD bekreftet før implementasjon): resolve_model (rolle->modell-id, ukjent profil feiler fail-fast), build_citations (eksakte char-spans, verdict- ekskludering, uncitable kontekst -> raise FØR spend), persist_run_artifacts (deterministiske bytes; validator/checker-avgjørelser speilet VERBATIM fra RunResult — §9 non-konflatering). Run-path-only, aldri importert av tester: SdkModelClient (claude-agent-sdk 0.2.110 verifisert mot installert pakke; max_turns=1, tools=[], max_budget_usd per kall; manglende usage -> None så §8-meteret feiler lukket) + run_s10 (kontrakter FØR klient §10, BudgetExceeded som strukturert stopp). 178/178 uten nøkkel; ruff+mypy --strict rene; fire detach-bevis røde -> revertert grønne. Live-kjøringen gjenstår (credential-gated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdSfQdND84oeq2mbjueLTS
This commit is contained in:
parent
22bfc80dda
commit
0238507df4
5 changed files with 583 additions and 0 deletions
105
src/portfolio_optimiser_claude/sdk_client.py
Normal file
105
src/portfolio_optimiser_claude/sdk_client.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""The Claude Agent SDK model client — RUN-PATH ONLY (honesty rule §1, §11).
|
||||
|
||||
This module is the ONE place the programme touches a real model (S10, D6). It
|
||||
is never imported by the test suite — the offline suite proves the loop with
|
||||
the scripted stand-in, and this client slots into the same ``ModelClient``
|
||||
protocol seam. Each ``complete()`` is one bounded ``query()`` call: no tools,
|
||||
one turn, a first-class USD cap (``ClaudeAgentOptions.max_budget_usd``) ON TOP
|
||||
of the §8 token/round meter that the loop already charges.
|
||||
|
||||
Verified against claude-agent-sdk 0.2.110: ``query()`` yields
|
||||
``AssistantMessage`` (text blocks + real model id) and a closing
|
||||
``ResultMessage`` (provider-reported ``usage`` + ``total_cost_usd``). A reply
|
||||
without usage is passed through as ``None`` so the meter fails CLOSED (§8) —
|
||||
this client never invents a count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from claude_agent_sdk import (
|
||||
AssistantMessage,
|
||||
ClaudeAgentOptions,
|
||||
ResultMessage,
|
||||
TextBlock,
|
||||
query,
|
||||
)
|
||||
|
||||
from portfolio_optimiser_claude.contracts import ModelMapContract, resolve_model
|
||||
from portfolio_optimiser_claude.loop import ModelReply
|
||||
|
||||
# The provider-reported usage fields that make up the TOTAL token count (§8).
|
||||
_USAGE_TOKEN_FIELDS = (
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
)
|
||||
|
||||
|
||||
def _total_tokens(usage: dict[str, Any] | None) -> int | None:
|
||||
"""Sum the provider-reported token fields; no usage stays ``None`` (§8)."""
|
||||
if usage is None:
|
||||
return None
|
||||
counts = [usage[field] for field in _USAGE_TOKEN_FIELDS if isinstance(usage.get(field), int)]
|
||||
return sum(counts) if counts else None
|
||||
|
||||
|
||||
class SdkModelClient:
|
||||
"""``ModelClient`` over ``claude_agent_sdk.query()`` — one bounded call each.
|
||||
|
||||
``total_cost_usd`` accumulates the provider-reported cost across calls so
|
||||
the run can log it (D6); ``last_model`` carries the REAL model id from the
|
||||
latest reply for the §9 provenance stamp.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_map: ModelMapContract,
|
||||
*,
|
||||
profile: str = "anthropic",
|
||||
max_budget_usd_per_call: float = 0.25,
|
||||
) -> None:
|
||||
if max_budget_usd_per_call <= 0:
|
||||
raise ValueError(
|
||||
f"max_budget_usd_per_call must be positive, got {max_budget_usd_per_call}"
|
||||
)
|
||||
self._model_map = model_map
|
||||
self._profile = profile
|
||||
self._max_budget_usd_per_call = max_budget_usd_per_call
|
||||
self.total_cost_usd = 0.0
|
||||
self.last_model: str | None = None
|
||||
|
||||
def complete(self, prompt: str, *, role: str) -> ModelReply:
|
||||
model_id = resolve_model(self._model_map, role, profile=self._profile)
|
||||
return asyncio.run(self._complete_async(prompt, model_id))
|
||||
|
||||
async def _complete_async(self, prompt: str, model_id: str) -> ModelReply:
|
||||
options = ClaudeAgentOptions(
|
||||
model=model_id,
|
||||
max_turns=1, # a single completion — the agentic loop lives in loop.py, not here
|
||||
tools=[], # pure text completion: no tool surface, no silent egress
|
||||
max_budget_usd=self._max_budget_usd_per_call,
|
||||
)
|
||||
text_parts: list[str] = []
|
||||
reply_model: str | None = None
|
||||
usage_tokens: int | None = None
|
||||
async for message in query(prompt=prompt, options=options):
|
||||
if isinstance(message, AssistantMessage):
|
||||
if message.error is not None:
|
||||
raise RuntimeError(f"model call failed: {message.error}")
|
||||
reply_model = message.model
|
||||
text_parts.extend(
|
||||
block.text for block in message.content if isinstance(block, TextBlock)
|
||||
)
|
||||
elif isinstance(message, ResultMessage):
|
||||
if message.is_error:
|
||||
raise RuntimeError(f"model call failed: {message.subtype}: {message.errors}")
|
||||
usage_tokens = _total_tokens(message.usage)
|
||||
if message.total_cost_usd is not None:
|
||||
self.total_cost_usd += message.total_cost_usd
|
||||
if reply_model is not None:
|
||||
self.last_model = reply_model
|
||||
return ModelReply(text="".join(text_parts), usage_tokens=usage_tokens, model=reply_model)
|
||||
Loading…
Add table
Add a link
Reference in a new issue