ANTHROPIC_API_KEY is now the ONLY accepted credential. Through v0.1.0 the preflight cleared on CLAUDE_CODE_OAUTH_TOKEN, and run_s10 went further: an unset key printed "note: relying on the CLI's own credentials" and carried on. That note was not a warning, it was a decision - made silently, on the operator's behalf, about who pays. Both paths are gone; a run with no key refuses with exit 2 before anything is opened. Red first, both halves: _check_credentials refuses an OAuth-only env, and the run entrance is driven as a real subprocess with a deliberately missing bundle, so the credential refusal must win the race against the bundle error. Detach it and the process reaches navigate_bundle instead - a different exit code, no refusal line, the fallback back in the output. The positive control (key set) gets past the gate and fails on the bundle, so the gate is a gate and not a wall. 997 -> 1002, offline, no key in env. The SDK exception is now stated where a reader meets it, not implied: this framework runs on the Claude Agent SDK, which starts the Claude Code CLI it bundles as a subprocess. That is the SDK's intended use WITH an API key, and it is a deliberate, stated exception to the owner's rule that his own code never starts Claude Code. Rewriting to direct HTTP calls was weighed and declined - measuring what the Agent SDK offers is the point of D7. The repo is closed as a worked example. Two prose claims were corrected rather than left standing: run_s10.py is no longer byte-frozen (it carries exactly one change, and runs/s10/ is still the v0.1.0 run), and its two round() call sites moved 110->118, 130->138. The credential paragraph is prose under an existing heading, not a new section: test_readme_anchors_loadbearing.py pins 14 heading ids MEASURED on the published page and forbids re-deriving them. This order forbids push, so a new heading could not have been honestly re-measured. Version 0.1.1: pyproject.toml, uv.lock self-entry, CHANGELOG - 3 of 3. No version badge in README, no constant in src. v0.1.0 stands as released. Order 20260920T131502Z-7496226791-from-.claude. The older D7 mirroring order 20260913T053840Z-9473220509 is retired unexecuted: po closes at v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
6.5 KiB
Python
156 lines
6.5 KiB
Python
"""S10 — the ONE live model run of the programme (D6). RUN-PATH ONLY.
|
||
|
||
Wires the offline-proven pipeline (S6–S9) to the real Claude Agent SDK client
|
||
for a single controlled run on the micro-bundle: startup contracts validated
|
||
fail-fast BEFORE the client is built (§10), citations proven citable BEFORE any
|
||
spend (§9), the read-context navigated + experience-folded exactly as the
|
||
offline suite proves (§3 Step 1), the loop bounded by the §8 meter AND the
|
||
per-call USD cap, and the captured artifacts persisted for S11 with the cost
|
||
logged. Never imported by the test suite.
|
||
|
||
Run: uv run python -m portfolio_optimiser_claude.run_s10
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import os
|
||
from pathlib import Path
|
||
|
||
from portfolio_optimiser_claude.artifacts import (
|
||
build_citations,
|
||
persist_run_artifacts,
|
||
persist_stop_artifacts,
|
||
)
|
||
from portfolio_optimiser_claude.budget import BudgetExceeded, BudgetMeter
|
||
from portfolio_optimiser_claude.contracts import load_contracts
|
||
from portfolio_optimiser_claude.experience import (
|
||
CandidateFeatures,
|
||
VerdictStore,
|
||
fold_experience,
|
||
seed_store_from_bundle,
|
||
)
|
||
from portfolio_optimiser_claude.ir import load_validator_input
|
||
from portfolio_optimiser_claude.loop import run_project
|
||
from portfolio_optimiser_claude.okf import bundle_context, navigate_bundle
|
||
from portfolio_optimiser_claude.provenance import Provenance
|
||
from portfolio_optimiser_claude.sdk_client import SdkModelClient
|
||
from portfolio_optimiser_claude.validator import Rejection
|
||
|
||
_DEFAULT_BUNDLE = Path(__file__).resolve().parents[2] / "shared" / "examples" / "bygg-energi-mikro"
|
||
_PROPOSER_ROLE = "proposer"
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
parser = argparse.ArgumentParser(description="S10: the single live run (hard caps, D6).")
|
||
parser.add_argument("--bundle", type=Path, default=_DEFAULT_BUNDLE)
|
||
parser.add_argument("--out", type=Path, default=Path("runs") / "s10")
|
||
parser.add_argument("--max-rounds", type=int, default=12)
|
||
parser.add_argument("--max-tokens", type=int, default=150_000)
|
||
parser.add_argument("--max-budget-usd-per-call", type=float, default=0.25)
|
||
parser.add_argument("--max-debate-rounds", type=int, default=3)
|
||
parser.add_argument("--max-attempts", type=int, default=3)
|
||
parser.add_argument("--top-k", type=int, default=3)
|
||
args = parser.parse_args(argv)
|
||
|
||
if not os.environ.get("ANTHROPIC_API_KEY", "").strip():
|
||
# REFUSAL, never a fallback: an own API key is the only credential this
|
||
# run accepts. Through v0.1.0 an unset key merely printed a note and the
|
||
# run carried on, letting the bundled CLI resolve its own login — which
|
||
# would have charged a consumer subscription. The gate fires FIRST, so
|
||
# nothing is opened, built or spent before it.
|
||
print(
|
||
"REFUSED: ANTHROPIC_API_KEY is not set. It is the only accepted credential; "
|
||
"a Claude Code subscription token (CLAUDE_CODE_OAUTH_TOKEN) is NOT accepted "
|
||
"and must not be used to pay for a run. No model call was made."
|
||
)
|
||
return 2
|
||
|
||
# §10: ALL startup contracts schema-validated BEFORE any model client exists.
|
||
contracts = load_contracts(
|
||
data_source={"docs_dir": str(args.bundle), "top_k": args.top_k},
|
||
termination={"max_rounds": args.max_rounds, "max_tokens": args.max_tokens},
|
||
feedback={"decision": "approved", "rationale": "startup shape check (§10)"},
|
||
)
|
||
|
||
# §9: prove the context citable BEFORE any spend; §3 Step 1: navigate + fold.
|
||
concepts = navigate_bundle(args.bundle)
|
||
citations = build_citations(concepts)
|
||
ir_projection = load_validator_input(args.bundle)
|
||
store = VerdictStore()
|
||
seeded = seed_store_from_bundle(store, args.bundle)
|
||
context = fold_experience(
|
||
store,
|
||
CandidateFeatures.from_proposal(ir_projection),
|
||
bundle_context(args.bundle),
|
||
contracts.data_source.top_k,
|
||
)
|
||
|
||
meter = BudgetMeter(contracts.termination)
|
||
client = SdkModelClient(
|
||
contracts.model_map, max_budget_usd_per_call=args.max_budget_usd_per_call
|
||
)
|
||
print(
|
||
f"S10 live run: bundle={args.bundle.name} seeded_verdicts={seeded} "
|
||
f"caps: max_rounds={args.max_rounds} max_tokens={args.max_tokens} "
|
||
f"max_budget_usd_per_call={args.max_budget_usd_per_call}"
|
||
)
|
||
try:
|
||
result = run_project(
|
||
client,
|
||
context,
|
||
meter=meter,
|
||
max_debate_rounds=args.max_debate_rounds,
|
||
max_attempts=args.max_attempts,
|
||
default_project_id=ir_projection.project_id,
|
||
)
|
||
except BudgetExceeded as stop:
|
||
# §8: the structured stop event — report it, never a silent hang, and
|
||
# persist the spend (the first live run stopped here with NO record).
|
||
print(f"STOPPED by budget: {stop.kind} observed {stop.observed} > limit {stop.limit}")
|
||
print(f"cost so far: {client.total_cost_usd:.6f} USD")
|
||
stop_paths = persist_stop_artifacts(
|
||
args.out,
|
||
stop=stop,
|
||
termination=contracts.termination,
|
||
tokens_used=meter.tokens_used,
|
||
rounds_used=meter.rounds_used,
|
||
cost_usd=round(client.total_cost_usd, 6),
|
||
)
|
||
for name, path in sorted(stop_paths.items()):
|
||
print(f"artifact: {name} -> {path}")
|
||
return 3
|
||
|
||
provenance = Provenance(
|
||
citations=citations,
|
||
model=client.last_model or "unknown", # §9: the REAL id, neutral fallback
|
||
role=_PROPOSER_ROLE,
|
||
validator_decision=result.validator_decision,
|
||
tokens_used=meter.tokens_used,
|
||
)
|
||
paths = persist_run_artifacts(
|
||
args.out,
|
||
run=result,
|
||
provenance=provenance,
|
||
termination=contracts.termination,
|
||
tokens_used=meter.tokens_used,
|
||
rounds_used=meter.rounds_used,
|
||
cost_usd=round(client.total_cost_usd, 6),
|
||
)
|
||
outcome_kind = "rejected" if isinstance(result.outcome, Rejection) else "validated"
|
||
print(
|
||
f"result: validator={result.validator_decision} checker={result.checker_decision} "
|
||
f"attempts={result.attempts} outcome={outcome_kind}"
|
||
)
|
||
print(
|
||
f"usage: tokens={meter.tokens_used}/{contracts.termination.max_tokens} "
|
||
f"rounds={meter.rounds_used}/{contracts.termination.max_rounds}"
|
||
)
|
||
print(f"model: {client.last_model} cost: {client.total_cost_usd:.6f} USD")
|
||
for name, path in sorted(paths.items()):
|
||
print(f"artifact: {name} -> {path}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|