Coverage for scripts / capture_scaffold_fixtures.py: 100.00%

261 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1#!/usr/bin/env python3 

2"""Capture raw model output for the Mission scaffolder prompt. 

3 

4The scaffolder's sampling path is sensitive to the shapes a model 

5emits — different families default to different Pythonic idioms 

6(``r.get(...)``, comprehension dict-access, attribute walks). The 

7fixture-replay test (`tests/test_scaffold_fixture_replay.py`) 

8asserts that every captured response round-trips cleanly through 

9``_parse_response`` -> ``_normalize_sampled_criteria`` -> 

10``validate_criteria``. This script populates the fixture directory by calling 

11the fixture directory by calling each Bedrock model on a fixed set of 

12canonical directives. 

13 

14Usage: 

15 

16 # Capture every default model against every canonical directive 

17 # (writes one JSON file per model under 

18 # tests/fixtures/scaffold_responses/). 

19 python3 scripts/capture_scaffold_fixtures.py 

20 

21 # Capture a single model. 

22 python3 scripts/capture_scaffold_fixtures.py --model MODEL_ID 

23 

24 # Capture every uncaptured text-generation model line visible in the live 

25 # Bedrock catalog. The command prints the candidate list and paid-call 

26 # budget before making requests; one preferred inference profile represents 

27 # each underlying model line. Discovery captures four models concurrently 

28 # by default; use --workers to tune account throughput. 

29 python3 scripts/capture_scaffold_fixtures.py --discover-all-models 

30 

31 # Review the same live-catalog selection without invoking any model. 

32 python3 scripts/capture_scaffold_fixtures.py \ 

33 --discover-all-models --list-candidates 

34 

35 # Use a different region. 

36 python3 scripts/capture_scaffold_fixtures.py --region us-west-2 

37 

38The script needs AWS credentials with ``bedrock:InvokeModel`` access 

39to the listed models. Anthropic models — including the stock default — 

40additionally require the one-time Anthropic first-time-use case form on 

41the account; without it Bedrock answers ``FTUFormNotFilled`` and the 

42capture for that model fails with that code (see 

43``docs/CUSTOMIZATION.md``, Bedrock Model Selection). 

44 

45The configured default also consumes ``cdk.json`` 

46``context.bedrock.generation_reasoning``. At the stock ``high`` effort each capture 

47can use substantially more billed output tokens and take longer; 

48Claude models from Opus 4.7 onward additionally reject ``temperature``, 

49``topP``, and ``topK``, which GCO omits for the canonical default. 

50Failures (missing model access, transient ClientError) are reported 

51per-model and never abort the run — every model that does succeed lands 

52in the fixture directory and protects the validator path on every CI run 

53thereafter. 

54""" 

55 

56from __future__ import annotations 

57 

58import argparse 

59import asyncio 

60import datetime 

61import hashlib 

62import json 

63import os 

64import re 

65import sys 

66import tempfile 

67from dataclasses import dataclass 

68from pathlib import Path 

69from typing import Any, cast 

70 

71# Mirror the path-injection pattern used throughout the Mission tree 

72# so ``mission.*`` resolves regardless of how the script is launched. 

73_REPO_ROOT = Path(__file__).resolve().parent.parent 

74for _path in (str(_REPO_ROOT), str(_REPO_ROOT / "gco_mcp")): 

75 if _path not in sys.path: 

76 sys.path.insert(0, _path) 

77 

78import mission.criteria_scaffold as criteria_scaffold # noqa: E402 

79from mission.sampling import ( # noqa: E402 

80 BedrockSamplingBackend, 

81 SamplingPrompt, 

82 SamplingTransportError, 

83) 

84from mission.validation import MissionValidationError, validate_criteria # noqa: E402 

85 

86from gco.bedrock import ( # noqa: E402 

87 BedrockFTUFormNotAcceptedError, 

88 get_default_mission_model_id, 

89) 

90 

91# --------------------------------------------------------------------------- 

92# Defaults 

93# --------------------------------------------------------------------------- 

94 

95 

96@dataclass(frozen=True) 

97class _Directive: 

98 """A canonical directive paired with the allowlist used at scaffolding time. 

99 

100 The triplet covers the three template branches in 

101 ``criteria_scaffold._classify_directive``: search-flavoured 

102 directives (preferred shape: ``tool_call_succeeded``), 

103 metric-flavoured directives (preferred shape: 

104 ``metric_threshold``), and event-flavoured directives (preferred 

105 shape: ``event``). Any model that handles all three shapes is 

106 likely fine on the long tail. 

107 """ 

108 

109 slug: str 

110 text: str 

111 allowlist: tuple[str, ...] 

112 

113 

114_DIRECTIVES: tuple[_Directive, ...] = ( 

115 _Directive( 

116 slug="search_inference_docs", 

117 text="Find documentation about inference endpoints.", 

118 allowlist=("find_examples", "find_docs"), 

119 ), 

120 _Directive( 

121 slug="metric_drive_loss", 

122 text="Drive validation loss below 0.1 on the demo training tool.", 

123 allowlist=("find_examples",), 

124 ), 

125 _Directive( 

126 slug="event_goal_reached", 

127 text="Wait for the training job to emit a goal_reached event.", 

128 allowlist=("find_examples",), 

129 ), 

130) 

131 

132 

133# Default models to capture against. Every entry is a Bedrock 

134# inference-profile id that the calling principal must have invoke 

135# access to. Add a model here and the next ``capture`` run picks it 

136# up; failures (denied access, transient errors) are reported per- 

137# model and never abort the run. 

138# 

139# The list intentionally spans families (Anthropic, Amazon Nova, 

140# Meta Llama, Mistral, DeepSeek) and sizes (small / mid / large) 

141# so the replay test stays representative of the long tail of 

142# Pythonic emission shapes. When a new family or size lands in 

143# Bedrock, add it here and re-run the capture script. 

144_CURATED_MODELS: tuple[str, ...] = ( 

145 # Anthropic family — also the family the configured default belongs to. 

146 # NOTE: Anthropic models require a one-time First-Time-Use (FTU) form per 

147 # account/org before the first invoke; capture fails with 

148 # ``FTUFormNotFilled`` until it is submitted. 

149 "us.anthropic.claude-sonnet-4-5-20250929-v1:0", 

150 "us.anthropic.claude-haiku-4-5-20251001-v1:0", 

151 "us.anthropic.claude-opus-4-5-20251101-v1:0", 

152 "us.anthropic.claude-3-haiku-20240307-v1:0", 

153 # Amazon Nova family — first-party, no FTU form. The configured GCO 

154 # default is prepended lazily by ``_default_models`` and deduplicated from 

155 # this curated set before any paid calls are made, whichever family it 

156 # belongs to. 

157 "us.amazon.nova-pro-v1:0", 

158 "us.amazon.nova-lite-v1:0", 

159 "us.amazon.nova-micro-v1:0", 

160 "us.amazon.nova-2-lite-v1:0", 

161 # Meta Llama family — Llama 4 + recent Llama 3. 

162 "us.meta.llama4-maverick-17b-instruct-v1:0", 

163 "us.meta.llama4-scout-17b-instruct-v1:0", 

164 "us.meta.llama3-3-70b-instruct-v1:0", 

165 "us.meta.llama3-1-70b-instruct-v1:0", 

166 # Mistral family — the visible text-instruction profile. 

167 "us.mistral.pixtral-large-2502-v1:0", 

168 # DeepSeek family. 

169 "us.deepseek.r1-v1:0", 

170) 

171 

172 

173def _default_models() -> tuple[str, ...]: 

174 """Return the configured Mission default plus the curated set, in stable order. 

175 

176 Scaffold fixtures replay Mission sampling responses, so the canonical 

177 member of the set follows ``context.bedrock.mission_default_model_id``. 

178 """ 

179 return tuple(dict.fromkeys((get_default_mission_model_id(), *_CURATED_MODELS))) 

180 

181 

182_FIXTURE_DIR = _REPO_ROOT / "tests" / "fixtures" / "scaffold_responses" 

183 

184# Discovery is deliberately narrower than ``list-foundation-models``. Scaffold 

185# captures require a normal text message and a generated text answer through 

186# Converse; embeddings, rerankers, media transformers, speech-only models, and 

187# safety classifiers do not satisfy that contract even when their catalog entry 

188# mentions TEXT somewhere. 

189_GEOGRAPHY_PROFILE_PREFIX_RE = re.compile(r"^(?:global|us|us-gov|eu|apac|jp|au|ca|sa|il|mx)\.") 

190_NON_GENERATION_MODEL_FRAGMENTS = ( 

191 "embed", 

192 "rerank", 

193 "stable-", 

194 "stability.", 

195 "nova-2-sonic", 

196 "twelvelabs.", 

197 "safeguard", 

198) 

199 

200 

201def _base_model_id(model_id: str) -> str: 

202 """Collapse a geography-scoped profile id to its foundation-model line.""" 

203 return _GEOGRAPHY_PROFILE_PREFIX_RE.sub("", model_id) 

204 

205 

206def _is_text_generation_candidate(summary: dict[str, Any]) -> bool: 

207 """Return whether a live catalog entry can plausibly serve this fixture.""" 

208 model_id = str(summary.get("modelId", "")) 

209 return bool( 

210 summary.get("modelLifecycle", {}).get("status") == "ACTIVE" 

211 and "TEXT" in summary.get("inputModalities", []) 

212 and "TEXT" in summary.get("outputModalities", []) 

213 and not any(fragment in model_id for fragment in _NON_GENERATION_MODEL_FRAGMENTS) 

214 ) 

215 

216 

217def _fixture_model_ids(output_dir: Path) -> frozenset[str]: 

218 """Read exact model ids already represented under *output_dir*.""" 

219 model_ids: set[str] = set() 

220 for path in sorted(output_dir.glob("*.json")): 

221 payload = json.loads(path.read_text(encoding="utf-8")) 

222 model_id = payload.get("model_id") 

223 if not isinstance(model_id, str) or not model_id.strip(): 

224 raise ValueError(f"{path}: model_id must be a non-empty string") 

225 model_ids.add(model_id.strip()) 

226 return frozenset(model_ids) 

227 

228 

229def _profile_preference(profile_id: str) -> tuple[int, str]: 

230 """Prefer a global profile, then US, then another geography.""" 

231 if profile_id.startswith("global."): 

232 rank = 0 

233 elif profile_id.startswith("us."): 

234 rank = 1 

235 else: 

236 rank = 2 

237 return rank, profile_id 

238 

239 

240def _discover_all_models( 

241 region: str, 

242 output_dir: Path, 

243 *, 

244 bedrock_client: Any | None = None, 

245) -> tuple[str, ...]: 

246 """Discover every uncaptured text-generation candidate visible in Bedrock. 

247 

248 One inference profile represents each underlying model line (global is 

249 preferred where available). Direct on-demand model ids are added only when 

250 no profile represents that line. The configured/curated registry is folded 

251 in first so legacy-but-intentional captures remain required candidates. 

252 Existing fixtures suppress the entire underlying line, avoiding paid 

253 recapture under a different geography prefix. 

254 """ 

255 if bedrock_client is None: 

256 import boto3 

257 

258 bedrock_client = boto3.client("bedrock", region_name=region) 

259 

260 model_page = bedrock_client.list_foundation_models(byOutputModality="TEXT") 

261 active_models = { 

262 summary["modelId"]: summary 

263 for summary in model_page.get("modelSummaries", []) 

264 if _is_text_generation_candidate(summary) 

265 } 

266 

267 profiles: list[dict[str, Any]] = [] 

268 next_token: str | None = None 

269 while True: 

270 request: dict[str, Any] = { 

271 "typeEquals": "SYSTEM_DEFINED", 

272 "maxResults": 1000, 

273 } 

274 if next_token: 

275 request["nextToken"] = next_token 

276 page = bedrock_client.list_inference_profiles(**request) 

277 profiles.extend(page.get("inferenceProfileSummaries", [])) 

278 next_token = page.get("nextToken") 

279 if not next_token: 

280 break 

281 

282 existing_bases = {_base_model_id(model_id) for model_id in _fixture_model_ids(output_dir)} 

283 selected_by_base: dict[str, str] = {} 

284 

285 # Curated entries win exact-id selection, including intentional older 

286 # profiles that no longer appear as ACTIVE foundation models. 

287 for model_id in _default_models(): 

288 base = _base_model_id(model_id) 

289 if base not in existing_bases: 

290 selected_by_base.setdefault(base, model_id) 

291 curated_bases = set(selected_by_base) 

292 

293 for profile in profiles: 

294 if profile.get("status") != "ACTIVE": 

295 continue 

296 profile_id = profile.get("inferenceProfileId") 

297 if not isinstance(profile_id, str) or any( 

298 fragment in profile_id for fragment in _NON_GENERATION_MODEL_FRAGMENTS 

299 ): 

300 continue 

301 base_ids = { 

302 str(model.get("modelArn", "")).rsplit("/", 1)[-1] for model in profile.get("models", []) 

303 } 

304 for base in base_ids: 

305 if base not in active_models or base in existing_bases or base in curated_bases: 

306 continue 

307 current = selected_by_base.get(base) 

308 if current is None or _profile_preference(profile_id) < _profile_preference(current): 

309 selected_by_base[base] = profile_id 

310 

311 represented_bases = existing_bases | set(selected_by_base) 

312 for model_id, summary in active_models.items(): 

313 if model_id in represented_bases: 

314 continue 

315 if "ON_DEMAND" not in summary.get("inferenceTypesSupported", []): 

316 continue 

317 selected_by_base[model_id] = model_id 

318 

319 # Put Anthropic last: an account-wide FTU failure should not prevent other 

320 # providers from being captured in the same broad run. 

321 return tuple( 

322 sorted( 

323 selected_by_base.values(), 

324 key=lambda model_id: ("anthropic." in model_id, model_id), 

325 ) 

326 ) 

327 

328 

329# --------------------------------------------------------------------------- 

330# Capture helpers 

331# --------------------------------------------------------------------------- 

332 

333 

334def _slug_for_model(model_id: str) -> str: 

335 """Turn a Bedrock model id into a filesystem-safe slug. 

336 

337 ``us.anthropic.claude-haiku-4-5-20251001-v1:0`` -> 

338 ``us_anthropic_claude_haiku_4_5_20251001_v1_0``. The replacement 

339 is intentionally minimal — every non-alphanumeric becomes an 

340 underscore — so two model ids with different metadata produce 

341 different slugs. 

342 """ 

343 out = [] 

344 prev_underscore = False 

345 for ch in model_id: 

346 if ch.isalnum(): 

347 out.append(ch) 

348 prev_underscore = False 

349 elif not prev_underscore: 

350 out.append("_") 

351 prev_underscore = True 

352 return "".join(out).strip("_") 

353 

354 

355class _PromptAdapter: 

356 """Tiny stand-in for ``SamplingPrompt`` used by the scaffolder. 

357 

358 The Bedrock backend calls ``prompt.assemble()`` to render the 

359 string it sends to Converse. We bypass the full ``SamplingPrompt`` 

360 constructor (which requires session-shaped data we don't have at 

361 capture time) by giving the backend an object whose ``assemble`` 

362 returns the rendered scaffold prompt directly. 

363 """ 

364 

365 def __init__(self, text: str) -> None: 

366 self._text = text 

367 

368 def assemble(self) -> str: 

369 return self._text 

370 

371 

372def _backend_for_capture( 

373 model_id: str, 

374 region: str, 

375 *, 

376 read_timeout_seconds: int | None = None, 

377) -> BedrockSamplingBackend: 

378 """Preserve canonical provenance and optionally bound one capture request.""" 

379 if model_id == get_default_mission_model_id(): 

380 backend = BedrockSamplingBackend.from_canonical_default(region=region) 

381 else: 

382 backend = BedrockSamplingBackend(model_id=model_id, region=region) 

383 

384 if read_timeout_seconds is not None: 

385 import boto3 

386 from botocore.config import Config 

387 

388 backend._client = boto3.Session().client( 

389 "bedrock-runtime", 

390 region_name=region, 

391 config=Config( 

392 connect_timeout=min(10, read_timeout_seconds), 

393 read_timeout=read_timeout_seconds, 

394 ), 

395 ) 

396 return backend 

397 

398 

399async def _capture_one( 

400 backend: BedrockSamplingBackend, 

401 directive: _Directive, 

402) -> dict[str, Any]: 

403 """Render the scaffold prompt and capture the raw model response. 

404 

405 Returns a dict carrying the prompt inputs, the rendered prompt digest, and 

406 the untouched response so fixture provenance is auditable without storing 

407 the full repeated prompt in every capture. 

408 """ 

409 prompt_str = criteria_scaffold.build_scaffold_prompt( 

410 directive.text, 

411 allowlist=list(directive.allowlist), 

412 ) 

413 # ``BedrockSamplingBackend.sample`` is typed against 

414 # :class:`SamplingPrompt`, which requires session-shaped data we 

415 # don't have at capture time. The backend only ever calls 

416 # ``prompt.assemble()`` on its argument, so a duck-typed 

417 # ``_PromptAdapter`` is sufficient at runtime; cast to satisfy 

418 # mypy without weakening the production signature. 

419 raw = await backend.sample(cast(SamplingPrompt, _PromptAdapter(prompt_str))) 

420 

421 # A successful Converse call is not enough for a positive playback 

422 # fixture: the untouched raw text must also survive the exact production 

423 # parse/normalize/validate path. Fail closed before the per-model file is 

424 # published; a later live run can retry a model that emitted malformed or 

425 # semantically ambiguous criteria. 

426 parsed = criteria_scaffold._parse_response(raw) 

427 if len(parsed) > criteria_scaffold.DEFAULT_MAX_CRITERIA: 

428 parsed = parsed[: criteria_scaffold.DEFAULT_MAX_CRITERIA] 

429 parsed = criteria_scaffold._normalize_sampled_criteria(parsed) 

430 validated = validate_criteria(parsed) 

431 criteria_scaffold._validate_sampled_criteria_context( 

432 validated, 

433 directive=directive.text, 

434 allowlist=list(directive.allowlist), 

435 ) 

436 

437 return { 

438 "prompt_directive": directive.text, 

439 "prompt_allowlist": list(directive.allowlist), 

440 "prompt_sha256": hashlib.sha256(prompt_str.encode("utf-8")).hexdigest(), 

441 "raw_response": raw, 

442 } 

443 

444 

445async def _capture_model( 

446 model_id: str, 

447 region: str, 

448 output_dir: Path, 

449 *, 

450 read_timeout_seconds: int | None = None, 

451) -> bool: 

452 """Capture all canonical directives for one model. Returns False on failure. 

453 

454 Every directive is written into the same per-model JSON file under 

455 its ``slug`` key. A failure on one directive aborts the whole 

456 model's capture so the fixture file is either written wholesale 

457 or not at all — an incomplete fixture would silently weaken the 

458 replay test. 

459 """ 

460 backend = _backend_for_capture( 

461 model_id, 

462 region, 

463 read_timeout_seconds=read_timeout_seconds, 

464 ) 

465 captures: dict[str, dict[str, Any]] = {} 

466 for directive in _DIRECTIVES: 

467 try: 

468 captures[directive.slug] = await _capture_one(backend, directive) 

469 except BedrockFTUFormNotAcceptedError: 

470 # Account-wide gate, not a per-model failure: every Anthropic model 

471 # in the run would fail identically, so abort with the remediation 

472 # instead of repeating it once per model. 

473 raise 

474 except SamplingTransportError as exc: 

475 cause = f"; {exc.__cause__}" if exc.__cause__ is not None else "" 

476 print( 

477 f"[{model_id}] capture failed for {directive.slug!r}: {exc.code}: {exc}{cause}", 

478 file=sys.stderr, 

479 ) 

480 return False 

481 except (MissionValidationError, ValueError) as exc: 

482 details = ( 

483 f"; details={exc.details!r}" if isinstance(exc, MissionValidationError) else "" 

484 ) 

485 print( 

486 f"[{model_id}] capture rejected for {directive.slug!r}: " 

487 f"{type(exc).__name__}: {exc}{details}", 

488 file=sys.stderr, 

489 ) 

490 return False 

491 except Exception as exc: # noqa: BLE001 - surface and keep going 

492 print( 

493 f"[{model_id}] unexpected error for {directive.slug!r}: " 

494 f"{type(exc).__name__}: {exc}", 

495 file=sys.stderr, 

496 ) 

497 return False 

498 

499 output_path = output_dir / f"{_slug_for_model(model_id)}.json" 

500 payload = { 

501 "model_id": model_id, 

502 "region": region, 

503 "captured_at": datetime.datetime.now(datetime.UTC).isoformat(), 

504 "captures": captures, 

505 } 

506 serialized = json.dumps(payload, indent=2) + "\n" 

507 temporary_path: Path | None = None 

508 try: 

509 with tempfile.NamedTemporaryFile( 

510 mode="w", 

511 encoding="utf-8", 

512 dir=output_path.parent, 

513 prefix=f".{output_path.name}.", 

514 suffix=".tmp", 

515 delete=False, 

516 ) as handle: 

517 temporary_path = Path(handle.name) 

518 handle.write(serialized) 

519 handle.flush() 

520 os.fsync(handle.fileno()) 

521 assert temporary_path is not None 

522 os.chmod(temporary_path, 0o600) 

523 os.replace(temporary_path, output_path) 

524 temporary_path = None 

525 finally: 

526 if temporary_path is not None: 

527 temporary_path.unlink(missing_ok=True) 

528 print(f"[{model_id}] wrote {output_path.relative_to(_REPO_ROOT)}") 

529 return True 

530 

531 

532# --------------------------------------------------------------------------- 

533# CLI 

534# --------------------------------------------------------------------------- 

535 

536 

537def _positive_seconds(raw: str) -> int: 

538 """Argparse type for a strictly positive request timeout.""" 

539 value = int(raw) 

540 if value <= 0: 

541 raise argparse.ArgumentTypeError("timeout must be greater than zero") 

542 return value 

543 

544 

545def _positive_workers(raw: str) -> int: 

546 """Argparse type for a strictly positive model-worker count.""" 

547 value = int(raw) 

548 if value <= 0: 

549 raise argparse.ArgumentTypeError("workers must be greater than zero") 

550 return value 

551 

552 

553def _build_parser() -> argparse.ArgumentParser: 

554 parser = argparse.ArgumentParser( 

555 description=__doc__.split("\n", 1)[0] if __doc__ else "", 

556 ) 

557 selection = parser.add_mutually_exclusive_group() 

558 selection.add_argument( 

559 "--model", 

560 action="append", 

561 dest="models", 

562 default=None, 

563 help=( 

564 "Bedrock model id to capture against; repeatable. " 

565 "Defaults to a curated cross-family set." 

566 ), 

567 ) 

568 selection.add_argument( 

569 "--discover-all-models", 

570 action="store_true", 

571 help=( 

572 "Query the live Bedrock catalog and capture every uncaptured " 

573 "text-generation model line (one preferred profile per line)." 

574 ), 

575 ) 

576 parser.add_argument( 

577 "--list-candidates", 

578 action="store_true", 

579 help="Print the selected models and paid-call budget without invoking Bedrock.", 

580 ) 

581 parser.add_argument( 

582 "--region", 

583 default=os.environ.get("GCO_MISSION_BEDROCK_REGION", "us-east-1"), 

584 help="Bedrock region (default: us-east-1).", 

585 ) 

586 parser.add_argument( 

587 "--read-timeout-seconds", 

588 type=_positive_seconds, 

589 default=None, 

590 help=( 

591 "Per-Converse read timeout. Discovery defaults to 300 seconds; " 

592 "curated/explicit capture retains the normal backend timeout." 

593 ), 

594 ) 

595 parser.add_argument( 

596 "--workers", 

597 type=_positive_workers, 

598 default=None, 

599 help=( 

600 "Models to capture concurrently. Discovery defaults to 4; " 

601 "curated/explicit capture defaults to 1." 

602 ), 

603 ) 

604 parser.add_argument( 

605 "--output-dir", 

606 type=Path, 

607 default=_FIXTURE_DIR, 

608 help=("Directory to write fixtures into. Defaults to tests/fixtures/scaffold_responses/."), 

609 ) 

610 return parser 

611 

612 

613def _selected_models(args: argparse.Namespace) -> tuple[str, ...]: 

614 """Resolve explicit, discovered, or curated model selection.""" 

615 if args.discover_all_models: 

616 return _discover_all_models(args.region, args.output_dir) 

617 if args.models: 

618 return tuple(dict.fromkeys(args.models)) 

619 return _default_models() 

620 

621 

622async def _main_async(args: argparse.Namespace) -> int: 

623 models = _selected_models(args) 

624 if args.discover_all_models or args.list_candidates: 

625 print( 

626 f"Selected {len(models)} model candidate(s); a capture run makes " 

627 f"up to {len(models) * len(_DIRECTIVES)} paid Converse calls." 

628 ) 

629 for model_id in models: 

630 print(model_id) 

631 if args.list_candidates: 

632 return 0 

633 if not models: 

634 print("No uncaptured model candidates found.") 

635 return 0 

636 

637 read_timeout_seconds = getattr(args, "read_timeout_seconds", None) 

638 if read_timeout_seconds is None and args.discover_all_models: 

639 read_timeout_seconds = 300 

640 if args.discover_all_models: 

641 print(f"Per-request read timeout: {read_timeout_seconds}s") 

642 

643 args.output_dir.mkdir(parents=True, exist_ok=True) 

644 workers = getattr(args, "workers", None) 

645 if workers is None: 

646 workers = 4 if args.discover_all_models else 1 

647 if args.discover_all_models or workers > 1: 

648 print(f"Concurrent model workers: {workers}") 

649 

650 if workers == 1: 

651 successes = 0 

652 failures = 0 

653 for model_id in models: 

654 try: 

655 ok = await _capture_model( 

656 model_id, 

657 args.region, 

658 args.output_dir, 

659 read_timeout_seconds=read_timeout_seconds, 

660 ) 

661 except BedrockFTUFormNotAcceptedError as exc: 

662 # Preserve the historical curated-run behavior: a serial run 

663 # stops at the account-wide Anthropic prerequisite. 

664 print(f"\n{exc}", file=sys.stderr) 

665 print(f"Captured {successes} model(s) before aborting.", file=sys.stderr) 

666 return 1 

667 if ok: 

668 successes += 1 

669 else: 

670 failures += 1 

671 skipped = 0 

672 else: 

673 semaphore = asyncio.Semaphore(workers) 

674 anthropic_ftu = asyncio.Event() 

675 

676 async def _capture_concurrently( 

677 model_id: str, 

678 ) -> bool | None | BedrockFTUFormNotAcceptedError: 

679 async with semaphore: 

680 if anthropic_ftu.is_set() and "anthropic." in model_id: 

681 print(f"[{model_id}] skipped after Anthropic FTU failure", file=sys.stderr) 

682 return None 

683 try: 

684 return await _capture_model( 

685 model_id, 

686 args.region, 

687 args.output_dir, 

688 read_timeout_seconds=read_timeout_seconds, 

689 ) 

690 except BedrockFTUFormNotAcceptedError as exc: 

691 anthropic_ftu.set() 

692 return exc 

693 except Exception as exc: # noqa: BLE001 - isolate broad model failures 

694 print( 

695 f"[{model_id}] unexpected capture failure: {type(exc).__name__}: {exc}", 

696 file=sys.stderr, 

697 ) 

698 return False 

699 

700 results = await asyncio.gather(*(_capture_concurrently(model_id) for model_id in models)) 

701 successes = sum(result is True for result in results) 

702 failures = sum( 

703 result is False or isinstance(result, BedrockFTUFormNotAcceptedError) 

704 for result in results 

705 ) 

706 skipped = sum(result is None for result in results) 

707 ftu_error = next( 

708 (result for result in results if isinstance(result, BedrockFTUFormNotAcceptedError)), 

709 None, 

710 ) 

711 if ftu_error is not None: 

712 print(f"\n{ftu_error}", file=sys.stderr) 

713 

714 print(f"\nCaptured {successes} model(s); {failures} failed; {skipped} skipped.") 

715 # A non-zero exit when *every* model failed is useful for cron 

716 # wrappers; a partial-failure run still exits 0 so a denied or unsupported 

717 # model doesn't stop successful fixtures from being committed. 

718 return 0 if successes > 0 else 1 

719 

720 

721def main() -> int: 

722 args = _build_parser().parse_args() 

723 return asyncio.run(_main_async(args)) 

724 

725 

726if __name__ == "__main__": 

727 raise SystemExit(main())