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
« 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."""
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_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")
111class BedrockModelConfigurationError(RuntimeError):
112 """A canonical Bedrock model default could not be resolved safely."""
115@dataclass(frozen=True)
116class CodexAutopilotConfiguration:
117 """Validated Codex session defaults, independent of Converse callers."""
119 model_id: str
120 reasoning_effort: str
123@dataclass(frozen=True)
124class BedrockDefaultConfiguration:
125 """Validated canonical Bedrock model defaults and reasoning preferences.
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 """
135 mission_model_id: str
136 capacity_advisor_model_id: str
137 thinking_effort: str
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
149def _installed_cdk_json_path() -> Path | None:
150 """Locate installed data through this distribution's recorded file list.
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
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()
178 installed_path = _installed_cdk_json_path()
179 if installed_path is not None:
180 return installed_path
182 raise BedrockModelConfigurationError(
183 "Could not locate canonical cdk.json in a GCO source checkout or the "
184 f"installed {_DISTRIBUTION_NAME} distribution"
185 )
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")
193 context = payload.get("context")
194 if not isinstance(context, dict):
195 raise BedrockModelConfigurationError(f"{path}: context must be an object")
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
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()
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)
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 )
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 )
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 )
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 )
264 return BedrockDefaultConfiguration(
265 mission_model_id=mission_model_id,
266 capacity_advisor_model_id=capacity_advisor_model_id,
267 thinking_effort=effort,
268 )
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)
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()
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 )
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)
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()
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)
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()
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}")
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
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
361 return payload, path
364def get_default_bedrock_configuration(
365 cdk_json_path: Path | None = None,
366) -> BedrockDefaultConfiguration:
367 """Return the validated canonical Bedrock configuration from ``cdk.json``.
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)
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``.
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
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``.
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
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
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``.
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.
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)
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``.
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)
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)
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
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
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``.
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)
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
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
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
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.")
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.")
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
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
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.
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
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``.
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.
579 Two reasoning dialects are translated, selected from the model id:
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.
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
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
629 configuration = get_default_bedrock_configuration(cdk_json_path)
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
638 return translate(resolved_inference, configuration.thinking_effort)
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"
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)
667class BedrockFTUFormNotAcceptedError(RuntimeError):
668 """Anthropic's one-time first-time-use case form has not been submitted.
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.
680 It subclasses ``RuntimeError`` so existing callers that catch ``RuntimeError``
681 around the capacity advisor keep working.
682 """
684 def __init__(self, message: str | None = None) -> None:
685 super().__init__(message or BEDROCK_FTU_REMEDIATION)
688def raise_if_bedrock_ftu_form_error(error: BaseException) -> None:
689 """Convert an FTU-gated Bedrock failure into a hard, actionable error.
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
700def is_bedrock_ftu_form_error(error: BaseException | None) -> bool:
701 """Return whether ``error`` (or anything it was raised from) is the FTU gate.
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
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)
740class BedrockResponseTruncatedError(RuntimeError):
741 """A Converse response was cut off by an output-token limit.
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 """
751 def __init__(self, message: str | None = None) -> None:
752 super().__init__(message or BEDROCK_TRUNCATION_REMEDIATION)
755def extract_bedrock_converse_text(response: Mapping[str, Any]) -> str:
756 """Return the first non-empty text block, skipping reasoning content.
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()
766 content = response["output"]["message"]["content"]
767 if not isinstance(content, list):
768 raise TypeError("Bedrock response content must be a list")
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
777 raise IndexError("Bedrock response contains no non-empty text block")
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]