Coverage for gco / bedrock.py: 100.00%

276 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +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_OPENCODE_MODEL_ID_KEY = "opencode_default_model_id" 

21_EMBEDDING_MODEL_ID_KEY = "embedding_model_id" 

22_GENERATION_REASONING_KEY = "generation_reasoning" 

23_LEGACY_THINKING_KEY = "thinking" 

24_THINKING_EFFORT_KEY = "effort" 

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

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

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

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

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

30#: error. 

31_LEGACY_DEFAULT_MODEL_ID_KEY = "default_model_id" 

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

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

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

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

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

37# to another family. 

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

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

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

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

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

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

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

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

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

47# once per (geography, model) pair. 

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

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

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

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

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

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

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

55# is the safe default rather than a guessed request shape. Opus 5.5 (the 

56# shipped default) goes further: adaptive thinking is always on and cannot be 

57# disabled, so the effort in ``output_config`` is its only reasoning control. 

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

59_CLAUDE_ADAPTIVE_THINKING_MODELS = frozenset( 

60 { 

61 "anthropic.claude-opus-5-5", 

62 "anthropic.claude-opus-5", 

63 "anthropic.claude-mythos-5", 

64 "anthropic.claude-fable-5", 

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

66 "anthropic.claude-mythos-preview", 

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

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

69 } 

70) 

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

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

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

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

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

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

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

78_CLAUDE_RESTRICTED_SAMPLING_MODELS = frozenset( 

79 { 

80 "anthropic.claude-fable-5", 

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

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

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

84 "anthropic.claude-opus-5", 

85 "anthropic.claude-opus-5-5", 

86 "anthropic.claude-sonnet-5", 

87 } 

88) 

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

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

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

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

93# future Converse callers cannot drift. 

94_OPENAI_UNSUPPORTED_SAMPLING_FIELDS = frozenset({"temperature"}) 

95_XAI_UNSUPPORTED_SAMPLING_FIELDS = frozenset({"temperature"}) 

96# Moonshot AI's Kimi K3 (the OpenCode engine default) is an always-on 

97# reasoning model and Bedrock rejects Converse ``temperature`` for it ("This 

98# model doesn't support the temperature field"). Kimi K2.5 still accepts it, 

99# so the rule is an enumerated allowlist like the Claude one rather than a 

100# provider-wide prefix match. 

101_MOONSHOT_RESTRICTED_SAMPLING_MODELS = frozenset({"moonshotai.kimi-k3"}) 

102_MOONSHOT_UNSUPPORTED_SAMPLING_FIELDS = frozenset({"temperature"}) 

103BEDROCK_READ_TIMEOUT_SECONDS = 3600 

104_DISTRIBUTION_NAME = "gco-cli" 

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

106_SOURCE_CDK_JSON = _SOURCE_ROOT / "cdk.json" 

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

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

109 

110 

111class BedrockModelConfigurationError(RuntimeError): 

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

113 

114 

115@dataclass(frozen=True) 

116class CodexAutopilotConfiguration: 

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

118 

119 model_id: str 

120 reasoning_effort: str 

121 

122 

123@dataclass(frozen=True) 

124class BedrockDefaultConfiguration: 

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

126 

127 ``mission_model_id`` and ``capacity_advisor_model_id`` are deliberately 

128 separate knobs: repointing Mission sampling and repointing the capacity 

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

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

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

132 :func:`build_bedrock_converse_options` anyway. 

133 """ 

134 

135 mission_model_id: str 

136 capacity_advisor_model_id: str 

137 thinking_effort: str 

138 

139 

140def _source_cdk_json_path() -> Path | None: 

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

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

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

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

145 return _SOURCE_CDK_JSON 

146 return None 

147 

148 

149def _installed_cdk_json_path() -> Path | None: 

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

151 

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

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

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

155 records the actual relocated path and therefore remains authoritative. 

156 """ 

157 try: 

158 distribution = metadata.distribution(_DISTRIBUTION_NAME) 

159 files = distribution.files or () 

160 for relative_path in files: 

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

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

163 except metadata.PackageNotFoundError: 

164 return None 

165 except Exception as exc: 

166 raise BedrockModelConfigurationError( 

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

168 ) from exc 

169 return None 

170 

171 

172def _canonical_cdk_json_path() -> Path: 

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

174 source_path = _source_cdk_json_path() 

175 if source_path is not None: 

176 return source_path.resolve() 

177 

178 installed_path = _installed_cdk_json_path() 

179 if installed_path is not None: 

180 return installed_path 

181 

182 raise BedrockModelConfigurationError( 

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

184 f"installed {_DISTRIBUTION_NAME} distribution" 

185 ) 

186 

187 

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

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

190 if not isinstance(payload, dict): 

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

192 

193 context = payload.get("context") 

194 if not isinstance(context, dict): 

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

196 

197 bedrock = context.get(_BEDROCK_CONTEXT_KEY) 

198 if not isinstance(bedrock, dict): 

199 raise BedrockModelConfigurationError( 

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

201 ) 

202 return bedrock 

203 

204 

205def _generation_model_id_from_block( 

206 bedrock: Mapping[str, Any], 

207 key: str, 

208 path: Path, 

209) -> str: 

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

211 model_id = bedrock.get(key) 

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

213 raise BedrockModelConfigurationError( 

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

215 ) 

216 return model_id.strip() 

217 

218 

219def _bedrock_configuration_from_payload( 

220 payload: Any, 

221 path: Path, 

222) -> BedrockDefaultConfiguration: 

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

224 bedrock = _bedrock_block_from_payload(payload, path) 

225 

226 if _LEGACY_DEFAULT_MODEL_ID_KEY in bedrock: 

227 raise BedrockModelConfigurationError( 

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

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

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

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

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

233 ) 

234 

235 mission_model_id = _generation_model_id_from_block(bedrock, _MISSION_MODEL_ID_KEY, path) 

236 capacity_advisor_model_id = _generation_model_id_from_block( 

237 bedrock, _CAPACITY_ADVISOR_MODEL_ID_KEY, path 

238 ) 

239 

240 if _LEGACY_THINKING_KEY in bedrock: 

241 raise BedrockModelConfigurationError( 

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

243 "retired generation-reasoning key. Rename " 

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

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

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

247 ) 

248 generation_reasoning = bedrock.get(_GENERATION_REASONING_KEY) 

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

250 if not isinstance(generation_reasoning, dict): 

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

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

253 raise BedrockModelConfigurationError( 

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

255 ) 

256 

257 effort = generation_reasoning.get(_THINKING_EFFORT_KEY) 

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

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

260 raise BedrockModelConfigurationError( 

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

262 ) 

263 

264 return BedrockDefaultConfiguration( 

265 mission_model_id=mission_model_id, 

266 capacity_advisor_model_id=capacity_advisor_model_id, 

267 thinking_effort=effort, 

268 ) 

269 

270 

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

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

273 bedrock = _bedrock_block_from_payload(payload, path) 

274 

275 model_id = bedrock.get(_CLAUDE_CODE_MODEL_ID_KEY) 

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

277 raise BedrockModelConfigurationError( 

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

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

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

281 ) 

282 return model_id.strip() 

283 

284 

285def _codex_configuration_from_payload( 

286 payload: Any, 

287 path: Path, 

288) -> CodexAutopilotConfiguration: 

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

290 bedrock = _bedrock_block_from_payload(payload, path) 

291 model_id = bedrock.get(_CODEX_MODEL_ID_KEY) 

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

293 raise BedrockModelConfigurationError( 

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

295 "must be a non-empty string" 

296 ) 

297 codex = bedrock.get(_CODEX_KEY) 

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

299 if not isinstance(codex, dict): 

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

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

302 raise BedrockModelConfigurationError( 

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

304 ) 

305 effort = codex.get(_CODEX_REASONING_EFFORT_KEY) 

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

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

308 raise BedrockModelConfigurationError( 

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

310 ) 

311 return CodexAutopilotConfiguration( 

312 model_id=model_id.strip(), 

313 reasoning_effort=effort, 

314 ) 

315 

316 

317def _opencode_model_id_from_payload(payload: Any, path: Path) -> str: 

318 """Extract and validate the OpenCode session model default.""" 

319 bedrock = _bedrock_block_from_payload(payload, path) 

320 

321 model_id = bedrock.get(_OPENCODE_MODEL_ID_KEY) 

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

323 raise BedrockModelConfigurationError( 

324 f"{path}: context.{_BEDROCK_CONTEXT_KEY}.{_OPENCODE_MODEL_ID_KEY} " 

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

326 "or run `gco stacks bedrock set-opencode-model <model-id>`." 

327 ) 

328 return model_id.strip() 

329 

330 

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

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

333 bedrock = _bedrock_block_from_payload(payload, path) 

334 

335 model_id = bedrock.get(_EMBEDDING_MODEL_ID_KEY) 

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

337 raise BedrockModelConfigurationError( 

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

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

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

341 ) 

342 return model_id.strip() 

343 

344 

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

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

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

348 if not path.is_file(): 

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

350 

351 try: 

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

353 except (OSError, UnicodeError) as exc: 

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

355 

356 try: 

357 payload = json.loads(raw_payload) 

358 except json.JSONDecodeError as exc: 

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

360 

361 return payload, path 

362 

363 

364def get_default_bedrock_configuration( 

365 cdk_json_path: Path | None = None, 

366) -> BedrockDefaultConfiguration: 

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

368 

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

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

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

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

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

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

375 """ 

376 payload, path = _canonical_payload(cdk_json_path) 

377 return _bedrock_configuration_from_payload(payload, path) 

378 

379 

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

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

382 

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

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

385 advisor resolves its own default through 

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

387 through :func:`get_default_claude_code_model_id`. 

388 """ 

389 return get_default_bedrock_configuration(cdk_json_path).mission_model_id 

390 

391 

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

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

394 

395 This is the model ``gco capacity ai-recommend`` and ``gco capacity predict`` 

396 use when no ``--model`` override is supplied. Mission sampling resolves 

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

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

399 """ 

400 return get_default_bedrock_configuration(cdk_json_path).capacity_advisor_model_id 

401 

402 

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

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

405 return get_default_bedrock_configuration(cdk_json_path).thinking_effort 

406 

407 

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

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

410 

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

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

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

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

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

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

417 

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

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

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

421 Path selection and trust boundaries match 

422 :func:`get_default_bedrock_configuration`. 

423 """ 

424 payload, path = _canonical_payload(cdk_json_path) 

425 return _embedding_model_id_from_payload(payload, path) 

426 

427 

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

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

430 

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

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

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

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

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

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

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

438 Path selection and trust boundaries match 

439 :func:`get_default_bedrock_configuration`. 

440 """ 

441 payload, path = _canonical_payload(cdk_json_path) 

442 return _claude_code_model_id_from_payload(payload, path) 

443 

444 

445def get_default_codex_configuration( 

446 cdk_json_path: Path | None = None, 

447) -> CodexAutopilotConfiguration: 

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

449 payload, path = _canonical_payload(cdk_json_path) 

450 return _codex_configuration_from_payload(payload, path) 

451 

452 

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

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

455 return get_default_codex_configuration(cdk_json_path).model_id 

456 

457 

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

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

460 return get_default_codex_configuration(cdk_json_path).reasoning_effort 

461 

462 

463def get_default_opencode_model_id(cdk_json_path: Path | None = None) -> str: 

464 """Return the checked-in OpenCode session model default from ``cdk.json``. 

465 

466 This is the Bedrock model ``gco autopilot --engine opencode`` hands to 

467 OpenCode, independent of every other default the same way the Claude Code 

468 and Codex keys are: repointing one interactive engine never moves another, 

469 and a malformed sibling block cannot fail this accessor. OpenCode has no 

470 engine-owned reasoning knob (the shipped Kimi K3 default reasons on its 

471 own), so unlike Codex the key is a single scalar. Path selection and trust 

472 boundaries match :func:`get_default_bedrock_configuration`. 

473 """ 

474 payload, path = _canonical_payload(cdk_json_path) 

475 return _opencode_model_id_from_payload(payload, path) 

476 

477 

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

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

480 return _NOVA_2_MODEL_ID_RE.search(model_id) is not None 

481 

482 

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

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

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

486 return base in _CLAUDE_ADAPTIVE_THINKING_MODELS 

487 

488 

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

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

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

492 return base in _CLAUDE_RESTRICTED_SAMPLING_MODELS 

493 

494 

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

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

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

498 return base.startswith("openai.") 

499 

500 

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

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

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

504 return base.startswith("xai.") 

505 

506 

507def _requires_moonshot_sampling_normalization(model_id: str) -> bool: 

508 """Return whether the id names a Kimi line that rejects Converse ``temperature``.""" 

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

510 return base in _MOONSHOT_RESTRICTED_SAMPLING_MODELS 

511 

512 

513def _nova_reasoning_options( 

514 inference_config: dict[str, Any], 

515 effort: str, 

516) -> dict[str, Any]: 

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

518 resolved = inference_config 

519 if effort == "high": 

520 resolved = { 

521 key: value 

522 for key, value in resolved.items() 

523 if key not in _NOVA_HIGH_EFFORT_UNSUPPORTED_FIELDS 

524 } 

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

526 if resolved: 

527 options["inferenceConfig"] = resolved 

528 options["additionalModelRequestFields"] = { 

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

530 } 

531 return options 

532 

533 

534def _claude_reasoning_options( 

535 inference_config: dict[str, Any], 

536 effort: str, 

537) -> dict[str, Any]: 

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

539 

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

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

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

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

544 """ 

545 resolved = { 

546 key: value 

547 for key, value in inference_config.items() 

548 if key not in _CLAUDE_UNSUPPORTED_SAMPLING_FIELDS 

549 } 

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

551 if resolved: 

552 options["inferenceConfig"] = resolved 

553 options["additionalModelRequestFields"] = { 

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

555 "output_config": {"effort": effort}, 

556 } 

557 return options 

558 

559 

560def build_bedrock_converse_options( 

561 model_id: str, 

562 *, 

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

564 cdk_json_path: Path | None = None, 

565 apply_default_reasoning: bool | None = None, 

566) -> dict[str, Any]: 

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

568 

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

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

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

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

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

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

575 an explicit override then remains independent of canonical configuration 

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

577 model-ID equality preserves the compatibility behavior. 

578 

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

580 

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

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

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

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

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

586 * OpenAI GPT, xAI Grok, and Moonshot AI Kimi K3 — unsupported 

587 ``temperature`` is dropped for canonical and explicit models; no 

588 Converse reasoning dialect is inferred. 

589 

590 A default model in neither reasoning dialect keeps its remaining 

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

592 """ 

593 resolved_inference = dict(inference_config or {}) 

594 if _requires_claude_sampling_normalization(model_id): 

595 resolved_inference = { 

596 key: value 

597 for key, value in resolved_inference.items() 

598 if key not in _CLAUDE_UNSUPPORTED_SAMPLING_FIELDS 

599 } 

600 if _is_openai_model(model_id): 

601 resolved_inference = { 

602 key: value 

603 for key, value in resolved_inference.items() 

604 if key not in _OPENAI_UNSUPPORTED_SAMPLING_FIELDS 

605 } 

606 if _is_xai_model(model_id): 

607 resolved_inference = { 

608 key: value 

609 for key, value in resolved_inference.items() 

610 if key not in _XAI_UNSUPPORTED_SAMPLING_FIELDS 

611 } 

612 if _requires_moonshot_sampling_normalization(model_id): 

613 resolved_inference = { 

614 key: value 

615 for key, value in resolved_inference.items() 

616 if key not in _MOONSHOT_UNSUPPORTED_SAMPLING_FIELDS 

617 } 

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

619 if apply_default_reasoning is False: 

620 return inference_only 

621 

622 if _supports_claude_adaptive_thinking(model_id): 

623 translate = _claude_reasoning_options 

624 elif _supports_nova_2_reasoning(model_id): 

625 translate = _nova_reasoning_options 

626 else: 

627 return inference_only 

628 

629 configuration = get_default_bedrock_configuration(cdk_json_path) 

630 

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

632 if apply_default_reasoning is True: 

633 raise BedrockModelConfigurationError( 

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

635 ) 

636 return inference_only 

637 

638 return translate(resolved_inference, configuration.thinking_effort) 

639 

640 

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

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

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

644BEDROCK_FTU_FORM_ERROR_CODE = "FTUFormNotFilled" 

645 

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

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

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

649BEDROCK_FTU_REMEDIATION = ( 

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

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

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

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

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

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

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

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

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

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

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

661 "GCO_MISSION_BEDROCK_MODEL_ID, or the cdk.json " 

662 "context.bedrock.mission_default_model_id and " 

663 "context.bedrock.capacity_advisor_default_model_id keys." 

664) 

665 

666 

667class BedrockFTUFormNotAcceptedError(RuntimeError): 

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

669 

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

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

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

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

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

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

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

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

678 propagates through the fallback handlers and surfaces the remediation. 

679 

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

681 around the capacity advisor keep working. 

682 """ 

683 

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

685 super().__init__(message or BEDROCK_FTU_REMEDIATION) 

686 

687 

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

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

690 

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

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

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

694 untouched. 

695 """ 

696 if is_bedrock_ftu_form_error(error): 

697 raise BedrockFTUFormNotAcceptedError() from error 

698 

699 

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

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

702 

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

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

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

706 """ 

707 seen: set[int] = set() 

708 current = error 

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

710 seen.add(id(current)) 

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

712 if isinstance(response, Mapping): 

713 error_block = response.get("Error") 

714 if ( 

715 isinstance(error_block, Mapping) 

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

717 ): 

718 return True 

719 if BEDROCK_FTU_FORM_ERROR_CODE in str(current): 

720 return True 

721 current = current.__cause__ 

722 return False 

723 

724 

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

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

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

728#: ceiling or under an explicitly configured cap. 

729BEDROCK_TRUNCATION_REMEDIATION = ( 

730 "Bedrock stopped generating before the answer was complete " 

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

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

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

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

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

736 "output window via --model." 

737) 

738 

739 

740class BedrockResponseTruncatedError(RuntimeError): 

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

742 

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

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

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

746 Mission engine would record a silently incomplete rationale. Subclasses 

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

748 Bedrock calls keep working. 

749 """ 

750 

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

752 super().__init__(message or BEDROCK_TRUNCATION_REMEDIATION) 

753 

754 

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

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

757 

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

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

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

761 be returned as if it were a complete answer. 

762 """ 

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

764 raise BedrockResponseTruncatedError() 

765 

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

767 if not isinstance(content, list): 

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

769 

770 for block in content: 

771 if not isinstance(block, Mapping): 

772 continue 

773 text = block.get("text") 

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

775 return text 

776 

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

778 

779 

780__all__ = [ 

781 "BEDROCK_FTU_FORM_ERROR_CODE", 

782 "BEDROCK_FTU_REMEDIATION", 

783 "BEDROCK_READ_TIMEOUT_SECONDS", 

784 "BEDROCK_TRUNCATION_REMEDIATION", 

785 "BedrockDefaultConfiguration", 

786 "BedrockFTUFormNotAcceptedError", 

787 "BedrockModelConfigurationError", 

788 "BedrockResponseTruncatedError", 

789 "CodexAutopilotConfiguration", 

790 "build_bedrock_converse_options", 

791 "extract_bedrock_converse_text", 

792 "get_default_bedrock_configuration", 

793 "get_default_bedrock_thinking_effort", 

794 "get_default_capacity_advisor_model_id", 

795 "get_default_claude_code_model_id", 

796 "get_default_codex_configuration", 

797 "get_default_codex_model_id", 

798 "get_default_codex_reasoning_effort", 

799 "get_default_embedding_model_id", 

800 "get_default_mission_model_id", 

801 "get_default_opencode_model_id", 

802 "is_bedrock_ftu_form_error", 

803 "raise_if_bedrock_ftu_form_error", 

804]