Coverage for gco / bedrock.py: 100.00%

259 statements  

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

1"""Shared Bedrock defaults loaded from the canonical ``cdk.json`` context.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import re 

7from collections.abc import Mapping 

8from dataclasses import dataclass 

9from importlib import metadata 

10from pathlib import Path 

11from typing import Any 

12 

13_BEDROCK_CONTEXT_KEY = "bedrock" 

14_MISSION_MODEL_ID_KEY = "mission_default_model_id" 

15_CAPACITY_ADVISOR_MODEL_ID_KEY = "capacity_advisor_default_model_id" 

16_CLAUDE_CODE_MODEL_ID_KEY = "claude_code_default_model_id" 

17_CODEX_MODEL_ID_KEY = "codex_default_model_id" 

18_CODEX_KEY = "codex" 

19_CODEX_REASONING_EFFORT_KEY = "reasoning_effort" 

20_EMBEDDING_MODEL_ID_KEY = "embedding_model_id" 

21_GENERATION_REASONING_KEY = "generation_reasoning" 

22_LEGACY_THINKING_KEY = "thinking" 

23_THINKING_EFFORT_KEY = "effort" 

24#: The pre-v6 single "advisory" key that fed BOTH Mission sampling and the 

25#: capacity advisor. Fully removed — one knob silently steering two features 

26#: was exactly the kind of read-the-docs-to-understand-it default this 

27#: project avoids — but its presence is still detected so an un-migrated 

28#: config fails with rename instructions instead of a confusing missing-key 

29#: error. 

30_LEGACY_DEFAULT_MODEL_ID_KEY = "default_model_id" 

31# Effort levels accepted in ``cdk.json``. This is deliberately the 

32# *intersection* of what the supported reasoning dialects accept: Nova 2 tops 

33# out at ``high``, and while Claude Opus 4.6/5 also accept ``xhigh`` and 

34# ``max``, allowing them here would let a config value that is valid for one 

35# default model become a hard ValidationException the moment the default moves 

36# to another family. 

37_SUPPORTED_THINKING_EFFORTS = frozenset({"low", "medium", "high"}) 

38# Codex has an independent Responses-API reasoning dialect. The pinned CLI's 

39# Bedrock catalog supports through xhigh; service tier remains provider-owned. 

40_SUPPORTED_CODEX_REASONING_EFFORTS = frozenset({"minimal", "low", "medium", "high", "xhigh"}) 

41_NOVA_2_MODEL_ID_RE = re.compile(r"(?:^|/)(?:[a-z0-9-]+\.)?amazon\.nova-2-[a-z0-9-]+-v\d+:\d+$") 

42# Nova 2 rejects these three at ``high`` effort only (lower efforts keep them). 

43_NOVA_HIGH_EFFORT_UNSUPPORTED_FIELDS = frozenset({"maxTokens", "temperature", "topP"}) 

44# Strip the geography scope from a system-defined inference-profile id so the 

45# adaptive-thinking allowlist below is written once per model line rather than 

46# once per (geography, model) pair. 

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

48# Claude model lines that accept ``thinking.type = "adaptive"``. Enumerated 

49# rather than pattern-matched because the distinction is not inferable from the 

50# id: Opus/Sonnet 4.6+ and the Mythos/Fable lines take adaptive thinking, while 

51# older Claude models (Sonnet 4.5, Opus 4.5, ...) require the legacy 

52# ``enabled`` + ``budget_tokens`` form and reject ``adaptive`` outright. An 

53# unlisted model therefore falls through to "no reasoning translation", which 

54# is the safe default rather than a guessed request shape. 

55# Source: https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-adaptive-thinking.html 

56_CLAUDE_ADAPTIVE_THINKING_MODELS = frozenset( 

57 { 

58 "anthropic.claude-opus-5", 

59 "anthropic.claude-mythos-5", 

60 "anthropic.claude-fable-5", 

61 "anthropic.claude-opus-4-7", 

62 "anthropic.claude-mythos-preview", 

63 "anthropic.claude-opus-4-6-v1", 

64 "anthropic.claude-sonnet-4-6", 

65 } 

66) 

67# Claude request compatibility is deliberately independent of adaptive-thinking 

68# support. Explicit model overrides never receive canonical reasoning fields, 

69# but they still need model-safe sampling controls before the provenance return. 

70# Opus 4.7/4.8/5 and Sonnet 5 deprecate these controls. Fable 5/5.1 accept 

71# only constrained values (temperature unset or 1.0, topP unset or >= 0.99, 

72# and no topK), so GCO's generic 0.1/0.2 temperatures are invalid there too. 

73# Enumerate verified model lines rather than guessing from version-like names. 

74_CLAUDE_RESTRICTED_SAMPLING_MODELS = frozenset( 

75 { 

76 "anthropic.claude-fable-5", 

77 "anthropic.claude-fable-5-1", 

78 "anthropic.claude-opus-4-7", 

79 "anthropic.claude-opus-4-8", 

80 "anthropic.claude-opus-5", 

81 "anthropic.claude-sonnet-5", 

82 } 

83) 

84_CLAUDE_UNSUPPORTED_SAMPLING_FIELDS = frozenset({"temperature", "topP", "topK"}) 

85# OpenAI GPT and xAI Grok inference profiles currently reject Converse 

86# ``temperature`` even for explicit overrides. Keep normalization in this 

87# shared request builder so Mission, the capacity advisor, fixture capture, and 

88# future Converse callers cannot drift. 

89_OPENAI_UNSUPPORTED_SAMPLING_FIELDS = frozenset({"temperature"}) 

90_XAI_UNSUPPORTED_SAMPLING_FIELDS = frozenset({"temperature"}) 

91BEDROCK_READ_TIMEOUT_SECONDS = 3600 

92_DISTRIBUTION_NAME = "gco-cli" 

93_SOURCE_ROOT = Path(__file__).resolve().parent.parent 

94_SOURCE_CDK_JSON = _SOURCE_ROOT / "cdk.json" 

95_SOURCE_CHECKOUT_MARKERS = (_SOURCE_ROOT / "app.py", _SOURCE_ROOT / "pyproject.toml") 

96_INSTALLED_DATA_PARTS = ("share", "gco", "cdk.json") 

97 

98 

99class BedrockModelConfigurationError(RuntimeError): 

100 """A canonical Bedrock model default could not be resolved safely.""" 

101 

102 

103@dataclass(frozen=True) 

104class CodexAutopilotConfiguration: 

105 """Validated Codex session defaults, independent of Converse callers.""" 

106 

107 model_id: str 

108 reasoning_effort: str 

109 

110 

111@dataclass(frozen=True) 

112class BedrockDefaultConfiguration: 

113 """Validated canonical Bedrock model defaults and reasoning preferences. 

114 

115 ``mission_model_id`` and ``capacity_advisor_model_id`` are deliberately 

116 separate knobs: repointing Mission sampling and repointing the capacity 

117 advisor are separate decisions. ``thinking_effort`` is shared because it 

118 expresses how hard *any* defaulted generation model should reason, and the 

119 translation into a model-specific request shape happens per model id in 

120 :func:`build_bedrock_converse_options` anyway. 

121 """ 

122 

123 mission_model_id: str 

124 capacity_advisor_model_id: str 

125 thinking_effort: str 

126 

127 

128def _source_cdk_json_path() -> Path | None: 

129 """Return the checkout-owned config path, never an ambient ancestor file.""" 

130 if all(marker.is_file() for marker in _SOURCE_CHECKOUT_MARKERS): 

131 # Return the expected path even when it is missing so selection is 

132 # fail-closed instead of falling through to an older installed copy. 

133 return _SOURCE_CDK_JSON 

134 return None 

135 

136 

137def _installed_cdk_json_path() -> Path | None: 

138 """Locate installed data through this distribution's recorded file list. 

139 

140 ``setuptools`` data files follow the installer's selected scheme, which may 

141 differ from the interpreter's default ``sysconfig`` scheme for ``--user``, 

142 ``--prefix``, ``--target``, pipx, or uvx installs. Distribution metadata 

143 records the actual relocated path and therefore remains authoritative. 

144 """ 

145 try: 

146 distribution = metadata.distribution(_DISTRIBUTION_NAME) 

147 files = distribution.files or () 

148 for relative_path in files: 

149 if tuple(relative_path.parts[-3:]) == _INSTALLED_DATA_PARTS: 

150 return Path(str(distribution.locate_file(relative_path))).resolve() 

151 except metadata.PackageNotFoundError: 

152 return None 

153 except Exception as exc: 

154 raise BedrockModelConfigurationError( 

155 f"Unable to inspect installed {_DISTRIBUTION_NAME} package data: {exc}" 

156 ) from exc 

157 return None 

158 

159 

160def _canonical_cdk_json_path() -> Path: 

161 """Select exactly one checkout-owned or distribution-owned config file.""" 

162 source_path = _source_cdk_json_path() 

163 if source_path is not None: 

164 return source_path.resolve() 

165 

166 installed_path = _installed_cdk_json_path() 

167 if installed_path is not None: 

168 return installed_path 

169 

170 raise BedrockModelConfigurationError( 

171 "Could not locate canonical cdk.json in a GCO source checkout or the " 

172 f"installed {_DISTRIBUTION_NAME} distribution" 

173 ) 

174 

175 

176def _bedrock_block_from_payload(payload: Any, path: Path) -> dict[str, Any]: 

177 """Validate the document structure down to the ``context.bedrock`` object.""" 

178 if not isinstance(payload, dict): 

179 raise BedrockModelConfigurationError(f"{path}: document root must be an object") 

180 

181 context = payload.get("context") 

182 if not isinstance(context, dict): 

183 raise BedrockModelConfigurationError(f"{path}: context must be an object") 

184 

185 bedrock = context.get(_BEDROCK_CONTEXT_KEY) 

186 if not isinstance(bedrock, dict): 

187 raise BedrockModelConfigurationError( 

188 f"{path}: context.{_BEDROCK_CONTEXT_KEY} must be an object" 

189 ) 

190 return bedrock 

191 

192 

193def _generation_model_id_from_block( 

194 bedrock: Mapping[str, Any], 

195 key: str, 

196 path: Path, 

197) -> str: 

198 """Validate one generation-model default key as a non-empty string.""" 

199 model_id = bedrock.get(key) 

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

201 raise BedrockModelConfigurationError( 

202 f"{path}: context.{_BEDROCK_CONTEXT_KEY}.{key} must be a non-empty string" 

203 ) 

204 return model_id.strip() 

205 

206 

207def _bedrock_configuration_from_payload( 

208 payload: Any, 

209 path: Path, 

210) -> BedrockDefaultConfiguration: 

211 """Extract and strictly validate the canonical generation-model configuration.""" 

212 bedrock = _bedrock_block_from_payload(payload, path) 

213 

214 if _LEGACY_DEFAULT_MODEL_ID_KEY in bedrock: 

215 raise BedrockModelConfigurationError( 

216 f"{path}: context.{_BEDROCK_CONTEXT_KEY}.{_LEGACY_DEFAULT_MODEL_ID_KEY} " 

217 "was split into two independent knobs and is no longer read. Rename " 

218 f"it to {_MISSION_MODEL_ID_KEY!r} (Mission sampling) and " 

219 f"{_CAPACITY_ADVISOR_MODEL_ID_KEY!r} (capacity advisor), then remove " 

220 f"the {_LEGACY_DEFAULT_MODEL_ID_KEY!r} key." 

221 ) 

222 

223 mission_model_id = _generation_model_id_from_block(bedrock, _MISSION_MODEL_ID_KEY, path) 

224 capacity_advisor_model_id = _generation_model_id_from_block( 

225 bedrock, _CAPACITY_ADVISOR_MODEL_ID_KEY, path 

226 ) 

227 

228 if _LEGACY_THINKING_KEY in bedrock: 

229 raise BedrockModelConfigurationError( 

230 f"{path}: context.{_BEDROCK_CONTEXT_KEY}.{_LEGACY_THINKING_KEY} is a " 

231 "retired generation-reasoning key. Rename " 

232 f"{_LEGACY_THINKING_KEY!r} to {_GENERATION_REASONING_KEY!r}, preserve " 

233 f"its {_THINKING_EFFORT_KEY!r} value, and remove the " 

234 f"{_LEGACY_THINKING_KEY!r} key before retrying." 

235 ) 

236 generation_reasoning = bedrock.get(_GENERATION_REASONING_KEY) 

237 reasoning_path = f"context.{_BEDROCK_CONTEXT_KEY}.{_GENERATION_REASONING_KEY}" 

238 if not isinstance(generation_reasoning, dict): 

239 raise BedrockModelConfigurationError(f"{path}: {reasoning_path} must be an object") 

240 if set(generation_reasoning) != {_THINKING_EFFORT_KEY}: 

241 raise BedrockModelConfigurationError( 

242 f"{path}: {reasoning_path} must contain only {_THINKING_EFFORT_KEY!r}" 

243 ) 

244 

245 effort = generation_reasoning.get(_THINKING_EFFORT_KEY) 

246 if not isinstance(effort, str) or effort not in _SUPPORTED_THINKING_EFFORTS: 

247 supported = ", ".join(sorted(_SUPPORTED_THINKING_EFFORTS)) 

248 raise BedrockModelConfigurationError( 

249 f"{path}: {reasoning_path}.{_THINKING_EFFORT_KEY} must be one of {supported}" 

250 ) 

251 

252 return BedrockDefaultConfiguration( 

253 mission_model_id=mission_model_id, 

254 capacity_advisor_model_id=capacity_advisor_model_id, 

255 thinking_effort=effort, 

256 ) 

257 

258 

259def _claude_code_model_id_from_payload(payload: Any, path: Path) -> str: 

260 """Extract and validate the Claude Code session model default.""" 

261 bedrock = _bedrock_block_from_payload(payload, path) 

262 

263 model_id = bedrock.get(_CLAUDE_CODE_MODEL_ID_KEY) 

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

265 raise BedrockModelConfigurationError( 

266 f"{path}: context.{_BEDROCK_CONTEXT_KEY}.{_CLAUDE_CODE_MODEL_ID_KEY} " 

267 "must be a non-empty string. Add the key to the deployment config " 

268 "or run `gco stacks bedrock set-claude-code-model <model-id>`." 

269 ) 

270 return model_id.strip() 

271 

272 

273def _codex_configuration_from_payload( 

274 payload: Any, 

275 path: Path, 

276) -> CodexAutopilotConfiguration: 

277 """Extract and independently validate Codex model and reasoning defaults.""" 

278 bedrock = _bedrock_block_from_payload(payload, path) 

279 model_id = bedrock.get(_CODEX_MODEL_ID_KEY) 

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

281 raise BedrockModelConfigurationError( 

282 f"{path}: context.{_BEDROCK_CONTEXT_KEY}.{_CODEX_MODEL_ID_KEY} " 

283 "must be a non-empty string" 

284 ) 

285 codex = bedrock.get(_CODEX_KEY) 

286 codex_path = f"context.{_BEDROCK_CONTEXT_KEY}.{_CODEX_KEY}" 

287 if not isinstance(codex, dict): 

288 raise BedrockModelConfigurationError(f"{path}: {codex_path} must be an object") 

289 if set(codex) != {_CODEX_REASONING_EFFORT_KEY}: 

290 raise BedrockModelConfigurationError( 

291 f"{path}: {codex_path} must contain only {_CODEX_REASONING_EFFORT_KEY!r}" 

292 ) 

293 effort = codex.get(_CODEX_REASONING_EFFORT_KEY) 

294 if not isinstance(effort, str) or effort not in _SUPPORTED_CODEX_REASONING_EFFORTS: 

295 supported = ", ".join(sorted(_SUPPORTED_CODEX_REASONING_EFFORTS)) 

296 raise BedrockModelConfigurationError( 

297 f"{path}: {codex_path}.{_CODEX_REASONING_EFFORT_KEY} must be one of {supported}" 

298 ) 

299 return CodexAutopilotConfiguration( 

300 model_id=model_id.strip(), 

301 reasoning_effort=effort, 

302 ) 

303 

304 

305def _embedding_model_id_from_payload(payload: Any, path: Path) -> str: 

306 """Extract and validate the text-embedding model default.""" 

307 bedrock = _bedrock_block_from_payload(payload, path) 

308 

309 model_id = bedrock.get(_EMBEDDING_MODEL_ID_KEY) 

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

311 raise BedrockModelConfigurationError( 

312 f"{path}: context.{_BEDROCK_CONTEXT_KEY}.{_EMBEDDING_MODEL_ID_KEY} " 

313 "must be a non-empty string. Mission memory embeds directives with " 

314 "this model; add the key to the deployment config." 

315 ) 

316 return model_id.strip() 

317 

318 

319def _canonical_payload(cdk_json_path: Path | None) -> tuple[Any, Path]: 

320 """Load and JSON-parse the selected canonical config, failing closed.""" 

321 path = cdk_json_path.resolve() if cdk_json_path is not None else _canonical_cdk_json_path() 

322 if not path.is_file(): 

323 raise BedrockModelConfigurationError(f"Canonical Bedrock config is not a file: {path}") 

324 

325 try: 

326 raw_payload = path.read_text(encoding="utf-8") 

327 except (OSError, UnicodeError) as exc: 

328 raise BedrockModelConfigurationError(f"Unable to read {path}: {exc}") from exc 

329 

330 try: 

331 payload = json.loads(raw_payload) 

332 except json.JSONDecodeError as exc: 

333 raise BedrockModelConfigurationError(f"Invalid JSON in {path}: {exc}") from exc 

334 

335 return payload, path 

336 

337 

338def get_default_bedrock_configuration( 

339 cdk_json_path: Path | None = None, 

340) -> BedrockDefaultConfiguration: 

341 """Return the validated canonical Bedrock configuration from ``cdk.json``. 

342 

343 An explicit path is strict. Without one, resolution uses only the config 

344 owned by this GCO source checkout or the config recorded in the installed 

345 ``gco-cli`` distribution. Current-working-directory and ancestor files are 

346 deliberately ignored so an unrelated project cannot change model routing. 

347 Once selected, a missing, unreadable, malformed, or incomplete canonical 

348 file fails closed rather than falling through to a stale copy. 

349 """ 

350 payload, path = _canonical_payload(cdk_json_path) 

351 return _bedrock_configuration_from_payload(payload, path) 

352 

353 

354def get_default_mission_model_id(cdk_json_path: Path | None = None) -> str: 

355 """Return the checked-in Mission sampling model default from ``cdk.json``. 

356 

357 This is the model Mission sampling uses when neither an explicit backend 

358 argument nor ``GCO_MISSION_BEDROCK_MODEL_ID`` is supplied. The capacity 

359 advisor resolves its own default through 

360 :func:`get_default_capacity_advisor_model_id`, and ``gco autopilot`` 

361 through :func:`get_default_claude_code_model_id`. 

362 """ 

363 return get_default_bedrock_configuration(cdk_json_path).mission_model_id 

364 

365 

366def get_default_capacity_advisor_model_id(cdk_json_path: Path | None = None) -> str: 

367 """Return the checked-in capacity-advisor model default from ``cdk.json``. 

368 

369 This is the model ``gco capacity advise`` (and its historical variant) 

370 uses when no ``--model`` override is supplied. Mission sampling resolves 

371 its own default through :func:`get_default_mission_model_id`, and 

372 ``gco autopilot`` through :func:`get_default_claude_code_model_id`. 

373 """ 

374 return get_default_bedrock_configuration(cdk_json_path).capacity_advisor_model_id 

375 

376 

377def get_default_bedrock_thinking_effort(cdk_json_path: Path | None = None) -> str: 

378 """Return the canonical default model's validated reasoning effort.""" 

379 return get_default_bedrock_configuration(cdk_json_path).thinking_effort 

380 

381 

382def get_default_embedding_model_id(cdk_json_path: Path | None = None) -> str: 

383 """Return the checked-in text-embedding model default from ``cdk.json``. 

384 

385 This is the model mission memory uses to embed directives for the 

386 ``{project}-mission-memory`` vector index. It is deliberately independent 

387 of the generation-model defaults (``mission_default_model_id`` and 

388 ``capacity_advisor_default_model_id``): embedding and text generation are 

389 different model families, and validation is equally independent — a 

390 malformed generation ``generation_reasoning`` block cannot fail this accessor. 

391 

392 The model's output dimensionality is a one-way door: the vector index is 

393 created with ``mission_memory.dimensions`` and query vectors must come 

394 from the same model at the same width, or search results are meaningless. 

395 Path selection and trust boundaries match 

396 :func:`get_default_bedrock_configuration`. 

397 """ 

398 payload, path = _canonical_payload(cdk_json_path) 

399 return _embedding_model_id_from_payload(payload, path) 

400 

401 

402def get_default_claude_code_model_id(cdk_json_path: Path | None = None) -> str: 

403 """Return the checked-in Claude Code session model default from ``cdk.json``. 

404 

405 This is the model ``gco autopilot`` hands to Claude Code, deliberately 

406 independent of the generation-model defaults (``mission_default_model_id`` 

407 and ``capacity_advisor_default_model_id``): repointing an interactive 

408 agent and repointing advisory Converse calls are separate decisions, and 

409 future agent runners get their own sibling keys. Validation is equally 

410 independent — a malformed generation ``generation_reasoning`` block cannot fail this 

411 accessor, and a missing Claude Code key cannot fail the generation path. 

412 Path selection and trust boundaries match 

413 :func:`get_default_bedrock_configuration`. 

414 """ 

415 payload, path = _canonical_payload(cdk_json_path) 

416 return _claude_code_model_id_from_payload(payload, path) 

417 

418 

419def get_default_codex_configuration( 

420 cdk_json_path: Path | None = None, 

421) -> CodexAutopilotConfiguration: 

422 """Return independently validated Codex session defaults from ``cdk.json``.""" 

423 payload, path = _canonical_payload(cdk_json_path) 

424 return _codex_configuration_from_payload(payload, path) 

425 

426 

427def get_default_codex_model_id(cdk_json_path: Path | None = None) -> str: 

428 """Return the checked-in Codex Bedrock inference-profile default.""" 

429 return get_default_codex_configuration(cdk_json_path).model_id 

430 

431 

432def get_default_codex_reasoning_effort(cdk_json_path: Path | None = None) -> str: 

433 """Return the checked-in Codex Responses API reasoning effort.""" 

434 return get_default_codex_configuration(cdk_json_path).reasoning_effort 

435 

436 

437def _supports_nova_2_reasoning(model_id: str) -> bool: 

438 """Return whether a model/profile identifier accepts Nova 2 reasoningConfig.""" 

439 return _NOVA_2_MODEL_ID_RE.search(model_id) is not None 

440 

441 

442def _supports_claude_adaptive_thinking(model_id: str) -> bool: 

443 """Return whether the identifier names a Claude line taking adaptive thinking.""" 

444 base = _INFERENCE_PROFILE_GEO_PREFIX_RE.sub("", model_id.rsplit("/", 1)[-1]) 

445 return base in _CLAUDE_ADAPTIVE_THINKING_MODELS 

446 

447 

448def _requires_claude_sampling_normalization(model_id: str) -> bool: 

449 """Return whether GCO's generic sampling controls are invalid for this Claude line.""" 

450 base = _INFERENCE_PROFILE_GEO_PREFIX_RE.sub("", model_id.rsplit("/", 1)[-1]) 

451 return base in _CLAUDE_RESTRICTED_SAMPLING_MODELS 

452 

453 

454def _is_openai_model(model_id: str) -> bool: 

455 """Return whether an id names an OpenAI foundation model or profile.""" 

456 base = _INFERENCE_PROFILE_GEO_PREFIX_RE.sub("", model_id.rsplit("/", 1)[-1]) 

457 return base.startswith("openai.") 

458 

459 

460def _is_xai_model(model_id: str) -> bool: 

461 """Return whether an id names an xAI foundation model or profile.""" 

462 base = _INFERENCE_PROFILE_GEO_PREFIX_RE.sub("", model_id.rsplit("/", 1)[-1]) 

463 return base.startswith("xai.") 

464 

465 

466def _nova_reasoning_options( 

467 inference_config: dict[str, Any], 

468 effort: str, 

469) -> dict[str, Any]: 

470 """Translate the canonical effort into Nova 2 ``reasoningConfig`` fields.""" 

471 resolved = inference_config 

472 if effort == "high": 

473 resolved = { 

474 key: value 

475 for key, value in resolved.items() 

476 if key not in _NOVA_HIGH_EFFORT_UNSUPPORTED_FIELDS 

477 } 

478 options: dict[str, Any] = {} 

479 if resolved: 

480 options["inferenceConfig"] = resolved 

481 options["additionalModelRequestFields"] = { 

482 "reasoningConfig": {"type": "enabled", "maxReasoningEffort": effort} 

483 } 

484 return options 

485 

486 

487def _claude_reasoning_options( 

488 inference_config: dict[str, Any], 

489 effort: str, 

490) -> dict[str, Any]: 

491 """Translate the canonical effort into Claude adaptive-thinking fields. 

492 

493 ``effort`` must ride in its own ``output_config`` object; Bedrock answers a 

494 ValidationException when it is nested inside ``thinking``. Unsupported 

495 sampling controls are dropped at every effort level because their removal 

496 is a model-wide change, not an effort-dependent one. 

497 """ 

498 resolved = { 

499 key: value 

500 for key, value in inference_config.items() 

501 if key not in _CLAUDE_UNSUPPORTED_SAMPLING_FIELDS 

502 } 

503 options: dict[str, Any] = {} 

504 if resolved: 

505 options["inferenceConfig"] = resolved 

506 options["additionalModelRequestFields"] = { 

507 "thinking": {"type": "adaptive"}, 

508 "output_config": {"effort": effort}, 

509 } 

510 return options 

511 

512 

513def build_bedrock_converse_options( 

514 model_id: str, 

515 *, 

516 inference_config: Mapping[str, Any] | None = None, 

517 cdk_json_path: Path | None = None, 

518 apply_default_reasoning: bool | None = None, 

519) -> dict[str, Any]: 

520 """Build model-safe optional kwargs for ``bedrock-runtime:Converse``. 

521 

522 Canonical reasoning preferences apply only when ``model_id`` is one of 

523 the configured defaults (Mission sampling or capacity advisor). Explicit 

524 third-party or other-model overrides retain caller-provided inference 

525 controls except fields that the selected provider rejects, and never 

526 receive canonical model-specific reasoning fields. Callers that know 

527 whether the model was defaulted should pass ``apply_default_reasoning``; 

528 an explicit override then remains independent of canonical configuration 

529 even when its model ID happens to match a default. With no provenance flag, 

530 model-ID equality preserves the compatibility behavior. 

531 

532 Two reasoning dialects are translated, selected from the model id: 

533 

534 * Claude adaptive thinking — ``thinking.type = "adaptive"`` plus the effort 

535 in its own ``output_config`` object. ``temperature``, ``topP``, and 

536 ``topK`` are dropped because Claude removed them from Opus 4.7 onward. 

537 * Nova 2 ``reasoningConfig`` — ``maxReasoningEffort``, with ``maxTokens``, 

538 ``temperature``, and ``topP`` dropped at ``high`` effort only. 

539 * OpenAI GPT and xAI Grok — unsupported ``temperature`` is dropped for 

540 canonical and explicit models; no Converse reasoning dialect is inferred. 

541 

542 A default model in neither reasoning dialect keeps its remaining 

543 caller-supplied inference controls and receives no reasoning fields. 

544 """ 

545 resolved_inference = dict(inference_config or {}) 

546 if _requires_claude_sampling_normalization(model_id): 

547 resolved_inference = { 

548 key: value 

549 for key, value in resolved_inference.items() 

550 if key not in _CLAUDE_UNSUPPORTED_SAMPLING_FIELDS 

551 } 

552 if _is_openai_model(model_id): 

553 resolved_inference = { 

554 key: value 

555 for key, value in resolved_inference.items() 

556 if key not in _OPENAI_UNSUPPORTED_SAMPLING_FIELDS 

557 } 

558 if _is_xai_model(model_id): 

559 resolved_inference = { 

560 key: value 

561 for key, value in resolved_inference.items() 

562 if key not in _XAI_UNSUPPORTED_SAMPLING_FIELDS 

563 } 

564 inference_only = {"inferenceConfig": resolved_inference} if resolved_inference else {} 

565 if apply_default_reasoning is False: 

566 return inference_only 

567 

568 if _supports_claude_adaptive_thinking(model_id): 

569 translate = _claude_reasoning_options 

570 elif _supports_nova_2_reasoning(model_id): 

571 translate = _nova_reasoning_options 

572 else: 

573 return inference_only 

574 

575 configuration = get_default_bedrock_configuration(cdk_json_path) 

576 

577 if model_id not in (configuration.mission_model_id, configuration.capacity_advisor_model_id): 

578 if apply_default_reasoning is True: 

579 raise BedrockModelConfigurationError( 

580 "Default Bedrock model changed while building its Converse request" 

581 ) 

582 return inference_only 

583 

584 return translate(resolved_inference, configuration.thinking_effort) 

585 

586 

587#: Bedrock error code returned (with HTTP 404) when the account has never 

588#: submitted the Anthropic first-time-use case form. Anthropic models are gated 

589#: behind it; first-party models are not. 

590BEDROCK_FTU_FORM_ERROR_CODE = "FTUFormNotFilled" 

591 

592#: Remediation shown when an Anthropic model is invoked before the one-time 

593#: use-case form has been submitted. Deliberately names both paths: the console 

594#: is the usual route, the API is what automation needs. 

595BEDROCK_FTU_REMEDIATION = ( 

596 "Amazon Bedrock rejected the request because this AWS account has not " 

597 "submitted Anthropic's one-time first-time-use (FTU) case form, which is " 

598 "required before any Anthropic model can be invoked.\n" 

599 "Submit it once per account (or organization) either way:\n" 

600 " - Console: Amazon Bedrock > Model access > request access to the " 

601 "Anthropic model and complete the use case details form.\n" 

602 " - CLI: aws bedrock put-use-case-for-model-access " 

603 "--form-data <base64-encoded-json>\n" 

604 "See https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html " 

605 "for the form fields. Alternatively, point GCO at a model that needs no FTU " 

606 "form (for example an Amazon Nova profile) with --model, " 

607 "GCO_MISSION_BEDROCK_MODEL_ID, or the cdk.json " 

608 "context.bedrock.mission_default_model_id and " 

609 "context.bedrock.capacity_advisor_default_model_id keys." 

610) 

611 

612 

613class BedrockFTUFormNotAcceptedError(RuntimeError): 

614 """Anthropic's one-time first-time-use case form has not been submitted. 

615 

616 Deliberately **not** a transport error. Every advisory Bedrock path in GCO 

617 degrades gracefully when a model is briefly unreachable — throttling, a 

618 dropped connection, a malformed response — because retrying or falling back 

619 to deterministic templates is the right answer for a transient fault. A 

620 missing FTU form is the opposite: it is a permanent, account-scoped 

621 misconfiguration that fails every subsequent call identically, so a silent 

622 fallback would quietly downgrade an entire Mission run (or hand back a 

623 template-derived answer) while hiding a one-line fix. This type therefore 

624 propagates through the fallback handlers and surfaces the remediation. 

625 

626 It subclasses ``RuntimeError`` so existing callers that catch ``RuntimeError`` 

627 around the capacity advisor keep working. 

628 """ 

629 

630 def __init__(self, message: str | None = None) -> None: 

631 super().__init__(message or BEDROCK_FTU_REMEDIATION) 

632 

633 

634def raise_if_bedrock_ftu_form_error(error: BaseException) -> None: 

635 """Convert an FTU-gated Bedrock failure into a hard, actionable error. 

636 

637 Call this at the top of a ``ClientError`` handler that would otherwise 

638 degrade gracefully, so the FTU case is escalated instead of absorbed. 

639 Non-FTU errors return without raising, leaving the caller's own handling 

640 untouched. 

641 """ 

642 if is_bedrock_ftu_form_error(error): 

643 raise BedrockFTUFormNotAcceptedError() from error 

644 

645 

646def is_bedrock_ftu_form_error(error: BaseException | None) -> bool: 

647 """Return whether ``error`` (or anything it was raised from) is the FTU gate. 

648 

649 The exception chain is walked because the capacity advisor re-raises the 

650 underlying ``ClientError`` as a ``RuntimeError``, so the CLI layer only ever 

651 sees the original code through ``__cause__``. 

652 """ 

653 seen: set[int] = set() 

654 current = error 

655 while current is not None and id(current) not in seen: 

656 seen.add(id(current)) 

657 response = getattr(current, "response", None) 

658 if isinstance(response, Mapping): 

659 error_block = response.get("Error") 

660 if ( 

661 isinstance(error_block, Mapping) 

662 and error_block.get("Code") == BEDROCK_FTU_FORM_ERROR_CODE 

663 ): 

664 return True 

665 if BEDROCK_FTU_FORM_ERROR_CODE in str(current): 

666 return True 

667 current = current.__cause__ 

668 return False 

669 

670 

671#: Remediation shown when a Converse response was cut off by an output-token 

672#: limit. GCO's own call sites set no ``maxTokens`` (the Converse default is 

673#: the model's maximum output length), so this fires only at the model's own 

674#: ceiling or under an explicitly configured cap. 

675BEDROCK_TRUNCATION_REMEDIATION = ( 

676 "Bedrock stopped generating before the answer was complete " 

677 '(stopReason="max_tokens"). GCO sets no output-token cap by default, so ' 

678 "the response hit either the model's own maximum output length or an " 

679 "explicitly configured maxTokens cap. Try a shorter or narrower request " 

680 "(for the capacity advisor: fewer instance types or regions), raise or " 

681 "remove any explicit maxTokens cap, or choose a model with a larger " 

682 "output window via --model." 

683) 

684 

685 

686class BedrockResponseTruncatedError(RuntimeError): 

687 """A Converse response was cut off by an output-token limit. 

688 

689 Raised instead of returning truncated text because every GCO consumer of 

690 Bedrock text does worse with a partial answer than with a clear failure: 

691 the capacity advisor would surface a confusing JSON-parse error and the 

692 Mission engine would record a silently incomplete rationale. Subclasses 

693 ``RuntimeError`` so existing callers that catch ``RuntimeError`` around 

694 Bedrock calls keep working. 

695 """ 

696 

697 def __init__(self, message: str | None = None) -> None: 

698 super().__init__(message or BEDROCK_TRUNCATION_REMEDIATION) 

699 

700 

701def extract_bedrock_converse_text(response: Mapping[str, Any]) -> str: 

702 """Return the first non-empty text block, skipping reasoning content. 

703 

704 Raises :class:`BedrockResponseTruncatedError` when the response was cut 

705 off by an output-token limit (``stopReason == "max_tokens"``); the 

706 truncation check runs first because a partial text block would otherwise 

707 be returned as if it were a complete answer. 

708 """ 

709 if response.get("stopReason") == "max_tokens": 

710 raise BedrockResponseTruncatedError() 

711 

712 content = response["output"]["message"]["content"] 

713 if not isinstance(content, list): 

714 raise TypeError("Bedrock response content must be a list") 

715 

716 for block in content: 

717 if not isinstance(block, Mapping): 

718 continue 

719 text = block.get("text") 

720 if isinstance(text, str) and text.strip(): 

721 return text 

722 

723 raise IndexError("Bedrock response contains no non-empty text block") 

724 

725 

726__all__ = [ 

727 "BEDROCK_FTU_FORM_ERROR_CODE", 

728 "BEDROCK_FTU_REMEDIATION", 

729 "BEDROCK_READ_TIMEOUT_SECONDS", 

730 "BEDROCK_TRUNCATION_REMEDIATION", 

731 "BedrockDefaultConfiguration", 

732 "BedrockFTUFormNotAcceptedError", 

733 "BedrockModelConfigurationError", 

734 "BedrockResponseTruncatedError", 

735 "CodexAutopilotConfiguration", 

736 "build_bedrock_converse_options", 

737 "extract_bedrock_converse_text", 

738 "get_default_bedrock_configuration", 

739 "get_default_bedrock_thinking_effort", 

740 "get_default_capacity_advisor_model_id", 

741 "get_default_claude_code_model_id", 

742 "get_default_codex_configuration", 

743 "get_default_codex_model_id", 

744 "get_default_codex_reasoning_effort", 

745 "get_default_embedding_model_id", 

746 "get_default_mission_model_id", 

747 "is_bedrock_ftu_form_error", 

748 "raise_if_bedrock_ftu_form_error", 

749]