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
« 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."""
3from __future__ import annotations
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
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")
99class BedrockModelConfigurationError(RuntimeError):
100 """A canonical Bedrock model default could not be resolved safely."""
103@dataclass(frozen=True)
104class CodexAutopilotConfiguration:
105 """Validated Codex session defaults, independent of Converse callers."""
107 model_id: str
108 reasoning_effort: str
111@dataclass(frozen=True)
112class BedrockDefaultConfiguration:
113 """Validated canonical Bedrock model defaults and reasoning preferences.
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 """
123 mission_model_id: str
124 capacity_advisor_model_id: str
125 thinking_effort: str
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
137def _installed_cdk_json_path() -> Path | None:
138 """Locate installed data through this distribution's recorded file list.
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
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()
166 installed_path = _installed_cdk_json_path()
167 if installed_path is not None:
168 return installed_path
170 raise BedrockModelConfigurationError(
171 "Could not locate canonical cdk.json in a GCO source checkout or the "
172 f"installed {_DISTRIBUTION_NAME} distribution"
173 )
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")
181 context = payload.get("context")
182 if not isinstance(context, dict):
183 raise BedrockModelConfigurationError(f"{path}: context must be an object")
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
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()
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)
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 )
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 )
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 )
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 )
252 return BedrockDefaultConfiguration(
253 mission_model_id=mission_model_id,
254 capacity_advisor_model_id=capacity_advisor_model_id,
255 thinking_effort=effort,
256 )
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)
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()
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 )
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)
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()
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}")
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
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
335 return payload, path
338def get_default_bedrock_configuration(
339 cdk_json_path: Path | None = None,
340) -> BedrockDefaultConfiguration:
341 """Return the validated canonical Bedrock configuration from ``cdk.json``.
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)
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``.
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
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``.
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
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
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``.
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.
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)
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``.
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)
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)
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
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
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
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
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
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.")
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.")
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
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.
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
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``.
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.
532 Two reasoning dialects are translated, selected from the model id:
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.
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
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
575 configuration = get_default_bedrock_configuration(cdk_json_path)
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
584 return translate(resolved_inference, configuration.thinking_effort)
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"
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)
613class BedrockFTUFormNotAcceptedError(RuntimeError):
614 """Anthropic's one-time first-time-use case form has not been submitted.
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.
626 It subclasses ``RuntimeError`` so existing callers that catch ``RuntimeError``
627 around the capacity advisor keep working.
628 """
630 def __init__(self, message: str | None = None) -> None:
631 super().__init__(message or BEDROCK_FTU_REMEDIATION)
634def raise_if_bedrock_ftu_form_error(error: BaseException) -> None:
635 """Convert an FTU-gated Bedrock failure into a hard, actionable error.
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
646def is_bedrock_ftu_form_error(error: BaseException | None) -> bool:
647 """Return whether ``error`` (or anything it was raised from) is the FTU gate.
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
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)
686class BedrockResponseTruncatedError(RuntimeError):
687 """A Converse response was cut off by an output-token limit.
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 """
697 def __init__(self, message: str | None = None) -> None:
698 super().__init__(message or BEDROCK_TRUNCATION_REMEDIATION)
701def extract_bedrock_converse_text(response: Mapping[str, Any]) -> str:
702 """Return the first non-empty text block, skipping reasoning content.
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()
712 content = response["output"]["message"]["content"]
713 if not isinstance(content, list):
714 raise TypeError("Bedrock response content must be a list")
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
723 raise IndexError("Bedrock response contains no non-empty text block")
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]