Skip to content

feat(models): add Model.model_info with capabilities and limits (Python) - #5627

Merged
mkmeral merged 15 commits into
strands-agents:mainfrom
strandly-the-agent:port/model-info-to-py
Oct 30, 2026
Merged

mkmeral merged 15 commits into
strands-agents:mainfrom
strandly-the-agent:port/model-info-to-py

Conversation

@strandly-the-agent

@strandly-the-agent strandly-the-agent commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Requested by: @mkmeral — "can you port this to python?" (on #5313)

Human Overview

Description

Python port of #5313 (eed3a17). Every Model gets model_info: ModelInfo, resolved on each read, later layers winning: the shared shared/model-info.json entry for the model id → the context_window_limit config value → the model_info config override. Model.stateful and Model.context_window_limit derive from it, so config, record and stateful cannot disagree. No provider hook: a provider that knows better than the shared file overrides the property as ModelInfo.merge(super().model_info, <its facts>, self.get_config().get("model_info")); OpenAIResponsesModel does so for stateful.

Readers switched to the record: estimate_utilization / context_window_limit (limits.context_window), Bedrock's "auto" cache gate (prompt_caching == "cache_points" replaces the "claude" in model_id substring test), the structured-output ladder (forced_tool_choice is False / native_structured_output is False skip a mechanism before any request), and a new Anthropic gate (prompt_caching == "unsupported" turns "auto" off). The hard-coded _CONTEXT_WINDOW_LIMITS table is gone; shared/model-info.json is the only source, copied into the package by a hatch build hook (git-ignored; runs on editable installs, wheels and sdists). get_config()["context_window_limit"] keeps being filled for known ids exactly as before.

Public API

  • strands.models.{ModelInfo, ModelCapabilities, ModelLimits, PromptCaching}; ModelInfo.merge(*parts) is the merge (classmethod, later defined fields win, None inherits). Frozen, keyword-only dataclasses; sections are always present (ModelInfo().limits.context_window is None, no guards needed).
  • BaseModelConfig.model_info: ModelInfo | None; Model.model_info (property); Model.stateful now derived.
  • Shared-data keys and multi-word values are re-cased to snake_case in the loader through explicit maps (cachePoints → "cache_points"), per the root AGENTS.md cross-SDK rule; the JSON stays camelCase.

Behavior changes

  • An unlisted Claude id on Bedrock no longer auto-caches under "auto" (listed ids and resolvable ARNs unchanged; explicit strategy="anthropic" still forces). Same as TS. Affected: anthropic.claude-v2*, claude-instant-v1, gateway ids containing claude, provisioned-model/* ARNs. Fix, if wanted, is entries in shared/model-info.json.
  • context_window_limit now resolves for providers that never called resolve_config_metadata (Ollama, LiteLLM, SageMaker, …) and for model_info-overridden ids.
  • import strands reads the 16 KB JSON copy once; a missing or malformed copy fails with a message naming shared/model-info.json and the re-install step.

Related Issues

Port of #5313. #780. Design: #5305.

Documentation PR

None; AGENTS.md provider contract updated in this PR.

Type of Change

New feature

Testing

On 9466e5a from strands-py/: ruff format --check src tests → 682 files already formatted; ruff check src tests → All checks passed; mypy on the touched modules → Success; pytest tests/strands → 8155 passed, 38 skipped. Earlier on a822b1e: mypy ./src ./tests_typing → Success: no issues found in 367 source files; hatch build with the JSON copy deleted first → wheel's strands/models/model_info.json byte-identical to shared/model-info.json; sdist → pip wheel . outside the monorepo → same. Back-compat probe: every one of the 166 ids in the old _CONTEXT_WINDOW_LIMITS resolves to the identical value (0 mismatches); get_config()["context_window_limit"], context_window_limit and stateful agree per provider. Mutation check: 7 source mutants, all caught. tests_integ/models/test_model_context_windows.py repointed at the shared file (62 cases collect; needs live credentials to run).

  • I ran hatch run prepare (format + lint + mypy + unit tests, mypy with --cache-dir off NFS)

Additional Details

Traceability matrix (source test → target test)

strands-ts/src/models/__tests__/model-info.test.ts → tests/strands/models/test__model_info.py

TS test (describe mergeModelInfo) pytest counterpart
returns the base when nothing else is given test_model_info_merge_returns_the_base_when_nothing_else_is_given
inherits fields a later record leaves out or sets to undefined test_model_info_merge_inherits_fields_a_later_record_leaves_out_or_sets_to_none
lets a later record win over an earlier one test_model_info_merge_lets_a_later_record_win_over_an_earlier_one
leaves out sections with nothing known test_model_info_merge_returns_empty_sections_when_nothing_is_known
returns a frozen record test_model_info_merge_returns_a_frozen_record (asserts AttributeError on assignment, the frozen=True analog of Object.isFrozen)

strands-ts/src/models/__tests__/model-info-lookup.test.ts → tests/strands/models/test__model_info.py

TS test (describe shared model info data) pytest counterpart
resolves every spelling of a model to its one entry test_lookup_model_resolves_every_spelling_of_a_model_to_its_one_entry
resolves a dated snapshot to the undated model test_lookup_model_resolves_a_dated_snapshot_to_the_undated_model
does not read inherited object keys test_lookup_model_does_not_read_inherited_object_keys (probes keys / x.items, the Python analog of toString / constructor)
ignores a trailing Bedrock version suffix test_lookup_model_ignores_a_trailing_bedrock_version_suffix
returns undefined for unknown ids test_lookup_model_returns_none_for_unknown_ids
returns the entry facts and nothing for unknown ids test_lookup_model_info_returns_the_entry_facts_and_nothing_for_unknown_ids
keeps getContextWindowLimit working for ids the Python table already knew test_get_context_window_limit_keeps_working_for_ids_the_table_already_knew
exposes the context window default from the data test_default_context_window_limit_comes_from_the_data
has only the fields ModelInfo defines, with the right types, in every entry test_shared_data_has_only_the_fields_model_info_defines_with_the_right_types_in_every_entry

strands-ts/src/models/__tests__/model-info.test.node.ts → tests/strands/models/test__model_info.py

TS test pytest counterpart
matches shared/model-info.json at the repository root test_package_copy_matches_the_shared_data

strands-ts/src/models/__tests__/model.test.ts → tests/strands/models/test_model.py

TS test (describe info) pytest counterpart (class TestModelInfo)
is all unknown for a provider with no model id, and unknown stateful reads as false test_is_all_unknown_without_a_model_id_and_unknown_stateful_reads_as_false
resolves facts from the shared model info for any provider by modelId test_resolves_facts_from_the_shared_model_info_by_model_id
lets a provider put its facts over the shared model info, with the user override over both test_lets_a_provider_put_its_facts_over_the_shared_model_info_with_the_user_override_over_both
lets the info override set stateful, and the stateful getter reads it test_lets_the_override_set_stateful_and_the_stateful_property_reads_it
lets the info override win over contextWindowLimit test_lets_the_override_win_over_context_window_limit
takes the context window from the config test_takes_the_context_window_from_the_config
reads stateful from what a provider puts in the record test_reads_stateful_from_what_a_provider_puts_in_the_record
reflects config changes on the next read test_reflects_config_changes_on_the_next_read
estimateUtilization › uses the context window from info, including an info override covered by TestModelInfo::test_lets_the_override_win_over_context_window_limit + the pre-existing TestEstimateUtilization suite, which now reads through model_info (estimate_utilization → context_window_limit → model_info.limits.context_window)

strands-ts/src/models/__tests__/bedrock.test.ts → tests/strands/models/test_bedrock.py

TS test pytest counterpart
auto-populates contextWindowLimit from the modelInfo override over the shared value test__init__auto_populates_context_window_limit_from_the_model_info_override
info › reads cache points for Claude model ids from the shared model info test_model_info_reads_cache_points_for_claude_from_the_shared_model_info
info › leaves caching unknown for other model ids test_model_info_leaves_caching_unknown_for_other_model_ids
info › does not auto-cache a Claude id the shared model info does not list test_cache_strategy_auto_does_not_cache_a_claude_id_the_shared_model_info_does_not_list
info › injects auto cache points on a model the info override says takes them test_cache_strategy_auto_maps_to_anthropic_when_the_model_info_override_says_cache_points
info › skips auto cache points on a Claude model the info override marks unsupported test_cache_strategy_auto_is_none_when_the_model_info_override_marks_caching_unsupported
starts at native output for a model the shared model info says cannot be forced test_stream_structured_output_starts_at_native_when_the_model_info_forbids_forcing
skips native output when the info override says the model lacks it test_stream_structured_output_skips_native_when_the_model_info_override_lacks_it
warns through warnOnce when auto finds no caching support no analog: mechanism deviation. Python keeps logger.warning rather than a hand-rolled warn-once (decision 2); the existing logger.warning call site is unchanged by this port, and no Python test pinned it before or after. The detection change it guards is covered by test_cache_strategy_auto_does_not_cache_a_claude_id_the_shared_model_info_does_not_list.
logs warning and disables caching for non-caching models (switched from console.warn spy to warnOnce) no analog needed: the Python test for this path never asserted the log, only the absent cache points; it is unchanged and still passes.
modelId: 'anthropic.claude-test-model' → 'anthropic.claude-sonnet-4-6' (2 request-shape tests) no analog needed: the Python request-shape tests use m1 and assert request fields, not caching, so the shared-data rename does not reach them.

strands-ts/src/models/__tests__/anthropic.test.ts → tests/strands/models/test_anthropic.py

TS test pytest counterpart
info › reads cache points from the shared model info test_model_info_reads_cache_points_from_the_shared_model_info
info › re-resolves the default maxTokens when modelId changes test_model_info_exposes_the_output_ceiling_from_the_shared_model_info — asserts model_info.limits.max_output_tokens re-resolves on update_config(model_id=...), not get_config()["max_tokens"] (gap 1: Python's max_tokens is Required[int], so there is no default to re-resolve)
info › defaults maxTokens to an info override of the output ceiling test_model_info_override_wins_over_the_shared_output_ceiling — same narrowing as the row above
info › defaults maxTokens to the model output ceiling from the shared model info no analog: gap 1. Python has no max_tokens default to derive, so there is no behavior to assert.
cache › is off under auto when the modelInfo override says caching is unsupported TestPromptCaching::test_auto_is_off_when_the_model_info_override_says_caching_is_unsupported
cache › caches regardless of the override when the anthropic strategy is explicit TestPromptCaching::test_caches_regardless_of_the_override_when_the_anthropic_strategy_is_explicit
getConfig maxTokens expectation changes (64_000 → 128_000, 4 tests) no analog: gap 1. Those TS tests assert the new shared-data-derived default; Python's max_tokens stays caller-supplied, so its equivalent tests are unchanged.

strands-ts/src/models/__tests__/native-output.test.ts → tests/strands/models/test__native_output.py

TS test pytest counterpart
skips mechanisms the model info says are unsupported, without a request (3 assertions) test_structured_output_mechanisms_skips_what_the_model_info_says_is_unsupported, parametrized over the same three capability records

strands-ts/src/models/openai/__tests__/responses.test.ts → tests/strands/models/test_openai_responses.py

TS test pytest counterpart
resolves stateful once per stream, not once per event test_stream_resolves_stateful_once_per_stream_not_once_per_event (counts get_config calls; 1 call for a 52-event stream)
stateful via the modelInfo override test_stateful (extended with a model_info.capabilities.stateful assertion) and test_stateful_model_info_override_wins_over_the_config_option

strands-ts/src/models/routing/__tests__/classifier-strategy.test.ts → tests/strands/models/routing/test_classifier_strategy.py

TS change Python counterpart
ConfigGuardModel gains override get modelInfo() { return {} } _ConfigGuardModel.model_info returns ModelInfo(), with the same one-line comment. Ported after the full-suite run failed on test_select_uses_only_explicit_candidate_evidence: the router's _reject_stateful (src/strands/models/routing/router.py:538) reads candidate.model.stateful, which now resolves through get_config() and tripped the guard that asserts the classifier never reads a candidate's config. Identical cause and fix to TS.

strands-ts/src/models/__tests__/defaults.test.ts → tests/strands/models/test_defaults.py

TS test pytest counterpart
does not strip unknown prefixes → strips leading segments one at a time, like the Python SDK, so unknown prefixes and ARNs resolve no analog needed: this TS change adopts the rule Python already had. The pre-existing test_strips_any_prefix_as_fallback and the ARN case in test_known_bedrock_openai already assert it, unchanged and passing.

strands-ts/test/integ/models/context-windows.test.ts → tests_integ/models/test_model_context_windows.py

TS change Python counterpart
Table source CONTEXT_WINDOW_LIMITS → model-info.json Repointed from strands.models._defaults._CONTEXT_WINDOW_LIMITS to shared/model-info.json (62 cases collected)

Source tests with no Python analog by design

Source test file Reason
strands-ts/src/models/__tests__/vercel.test.ts No Vercel provider in Python (stated in the plan).
strands-ts/src/conversation-manager/__tests__/*.test.ts (4 files) Those edits swap ad-hoc { getConfig } object literals for a real MockMessageModel because TS's getter needs a real instance. Python's equivalents already use MagicMock/MockedModelProvider, whose model.context_window_limit is mocked directly; they pass unchanged.
strands-ts/test/packages/assert-package-contents.js Covered by the hatch build wheel-contents gate in PORT_GATES.md (unzip -l dist/*.whl | grep model_info), plus test_package_copy_matches_the_shared_data.
Review ledger: API bar-raiser, validator, reviewer (independent passes)

API bar-raiser (no 🔴): kw_only=True on the records (positional construction locked field order) — done; sections always-present instead of Optional (removes six guards, info.limits.context_window type-checks without casts) — done, Python-idiom deviation from TS; a public merge because frozen dataclasses make the manual merge painful (TS keeps mergeModelInfo @internal; spread does it inline) — done, then moved onto the type as ModelInfo.merge per the owner; missing copy → actionable FileNotFoundError — done; docstring/TypeAlias nits — done.

Validator: 🔴 building a wheel from the sdist failed (the hook re-copied from ../shared, absent outside the monorepo) — fixed, hook skips when the sdist already carries the copy; verified sdist → wheel. 🟡 malformed copy died with a bare JSON error — now a ValueError naming the source. 🟡 estimateUtilization override test claimed covered but not asserted — added. Matrix: 12/12 spot-checked rows correct; 3 stale rows fixed.

Reviewer: correctness clean (lookup loop and regexes character-for-character vs TS, merge semantics, no recursion from get_config(), native-output fix complete, cache gates, loader maps). 🟡 the once-per-stream stateful test was tautological (no model_state, loose bound) — now passes model_state and asserts exactly 1 get_config() call; fails with 2 without the hoist. 🟢 resolve_config_metadata checks key presence where TS checks !== undefined (pre-existing Python semantics, left as is); the "inherited keys" lookup test can't fail in Python (kept as parity documentation).

opieter-aws review (7 issues, 684a2b8): 5 fixed — 🟡 PromptCaching carried TS casing ("cachePoints" → "cache_points", explicit load-time map, root AGENTS.md:56); 🟡 deprecated Anthropic cache_tools skipped the "auto"-unsupported gate (decision 4 superseded); 🟡 non-str model_id raised TypeError from the lookup (now unknown); 🟢 stale docs link; 🟢 narrating test name / docstring. 2 left as owner decisions (open questions 4 and 5): deployment-dependent providers reading the shared data, and Bedrock "auto" caching for ids the lookup cannot resolve — both TS-parity behavior changes already disclosed above.

Reviewer (684a2b8 → 9466e5a, independent pass, no 🔴): 🟡 the non-str model_id guard sat in Model.model_info only, while every provider's get_config() reaches the lookup first through resolve_config_metadata (AnthropicModel(model_id=1234).model_info still raised) — fixed, the guard is now in lookup_model_info itself and a BedrockModel(model_id=None) test covers the provider path. 🟡 _PROMPT_CACHING_VALUES[...] raised KeyError → import-time ValueError on a promptCaching value this version does not know, contradicting the skip-unknown-facts rule two lines above — fixed with .get() (unknown → None), table typed dict[str, PromptCaching], test added. 🟢 TS tab of context-estimation.mdx still linked defaults.ts — fixed, both tabs point at shared/model-info.json. 🟢 test_model.py comment said the shared file "says cache_points" — reworded. Noted, not changed: the Anthropic gate blocks only an explicit "unsupported" while Bedrock's deprecated path also disables on unknown — pre-existing divergence, outside this PR.

Decision log (deviations from the TS source, and why)
  1. AnthropicModel default max_tokens is not derived from limits.max_output_tokens (known gap, named in the plan). TS made maxTokens optional and resolved it through _resolvedMaxTokens() (user → modelInfo override → shared data → fixed 64 000). Python's AnthropicConfig.max_tokens is typing_extensions.Required[int], so every caller already supplies it and there is no default to derive. Changing requiredness would be an API change, not a translation, so _resolved_max_tokens is not ported. The data is still reachable as model.model_info.limits.max_output_tokens, which two new tests assert. Follow-up for the maintainers if they want parity: make max_tokens optional in Python and port _resolvedMaxTokens as a separate change.

  2. Bedrock's "no automatic caching" warning stays logger.warning instead of TS's warnOnce. strands-py/AGENTS.md:82 is explicit: "If you catch yourself building a process-global 'warn once' set over the logger, you almost certainly want warnings.warn instead." There is no warn_once helper in strands-py (checked: rg warn_once|_warned src/ finds nothing), so the plan's fallback — a module-level set keyed by model id — would contradict the repo's own authoritative rule. The warning line is left exactly as it is today; only the detection changed (shared model info instead of the "claude" in model_id substring match). Net effect vs TS: Python can log this line more than once per process.

  3. Anthropic's new "caching unsupported" gate uses warnings.warn(..., stacklevel=1). This is new code, so it follows the mechanism its own file already uses for the sibling notice (cache_config.strategy=... is an unknown cache strategy, same stacklevel=1 + "pins dedup to this line" comment). That gives the once-per-process dedup TS wanted from warnOnce while staying inside the house rule in decision 2. The two providers therefore use different mechanisms for similar notices — a pre-existing inconsistency in strands-py, preserved rather than papered over.

  4. The Python-only deprecated cache_tools path is not gated by the model info. TS has no cacheTools option (rg cacheTools strands-ts/src/models/anthropic.ts → nothing); _cacheSection covers only toolsTTL / messagesTTL / systemPromptTTL, whose three Python equivalents are all gated. Gating _resolve_deprecated_cache_tools too would change behavior for a config shape that has no TS counterpart, so it is left alone. Superseded by 684a2b8 (opieter-aws's issue 3): the deprecated path now runs _auto_caching_unsupported() first, like Bedrock's _build_deprecated_cache_tools_point, so tools do not keep cache_control when messages and the system prompt drop it.

  5. No model_id property override on any provider. TS added override get modelId() to OpenAIModel and GoogleModel because their _config.modelId can be unset while a default is substituted at request time, which would otherwise hide the shared facts. Python's providers already store the resolved model id in self.config (Bedrock resolves its regional default in __init__; gemini marks model_id as Required; openai/openai_responses read self.config["model_id"] directly), so Model.model_info reading get_config()["model_id"] already sees it. Nothing to add.

  6. The prefix-strip debug log is dropped. The old _defaults.get_context_window_limit logged logger.debug("model_id=<%s>, stripped_id=<%s> | resolved context window limit via prefix strip", ...). TS's lookupModel has no such log, and the lookup now runs on every model_info read rather than once per get_config, so keeping it would be noisy. No test asserted it (checked).

  7. Model.model_info keeps the dict/attribute dual read that context_window_limit had. get_config() is typed Any and real implementations return None (tests/fixtures/mocked_model_provider.py:53, TestModel in tests/strands/models/test_model.py), so the property reads via isinstance(config, dict) → .get(...), else getattr(config, ..., None). This is the behavior the replaced context_window_limit property already had; dropping it would break those providers.

  8. merge_model_info returns ModelInfo(capabilities=None, limits=None) where TS returns {}. Python's frozen dataclass with all-None fields is the structural equivalent of TS omitting an empty section, and ModelInfo() == ModelInfo(capabilities=None, limits=None), so == ModelInfo() is the assertion form in the ported tests (mirroring TS's toEqual({})). Superseded by test: make litellm integ test more robust #14 below: sections are always present.

  9. structured_output_mechanisms takes model_info as a 4th positional parameter with a None default, where TS defaults it to {}. Python's signature already differs from TS's (it takes a pre-computed unsupported set rather than provider/model_id), so the parameter is appended in the same position TS used, keeping every existing call site valid.

  10. ConfigurableModel in tests/strands/models/test_model.py was hoisted from inside TestEstimateUtilization to module level so the new TestModelInfo class can reuse it instead of defining a second near-identical test provider (docs/TESTING.md: reuse fixtures, don't hand-roll a mock model). Behavior-neutral: the class body is unchanged and self.ConfigurableModel(...) became ConfigurableModel(...).

  11. artifacts in [tool.hatch.build], not the wheel target. Hatchling reads only strands-py/.gitignore, so today the git-ignored copy would ship anyway (the ignore rule is in the monorepo-root .gitignore, which hatchling never sees). Naming it as a build-wide artifact makes the sdist and the wheel carry it even once the rule moves into strands-py/.gitignore; verified with the rule added locally. Found by agent-of-mkmeral on this PR.

  12. The loader ignores shared-file keys this version has no field for; it does not reject them. TS casts the
    JSON (modelInfoData as ModelInfoData) with no runtime validation, so an unknown key rides along harmlessly and
    only its test enforces the shape. My first cut mapped keys through a dict lookup, which raised KeyError at
    import time instead — and because shared/model-info.json is edited for both SDKs, a capability added for
    TypeScript first would have broken import strands for every Python user. _section() now skips unknown keys
    (matching TS's tolerance), two tests pin that, and the strict shape validation stays in
    test_shared_data_has_only_the_fields_model_info_defines_with_the_right_types_in_every_entry, exactly where TS
    keeps it. Found by injecting capabilities.reasoning into the copy and importing.

  13. Model.context_window_limit now resolves the shared data for every provider, not just the filling ones.
    It used to return get_config().get("context_window_limit") verbatim, so a provider that does not call
    resolve_config_metadata (Ollama, LlamaCpp, LiteLLM, SageMaker, Writer, LlamaAPI) returned None even for a
    model id the table knew. Reading through model_info means those providers now get the shared value. This is
    the TS behavior (modelInfo resolves lookupModelInfo(this.modelId) for every provider), and it is the point
    of the change, but it is a behavior widening for those six providers rather than a pure no-op: proactive
    compression and utilization estimates start working where they previously no-opped. get_config() is untouched
    for them, so nothing that reads the config dict changes.

  14. ModelInfo.capabilities and ModelInfo.limits are always-present empty records, not Optional.
    TS declares them readonly capabilities?: ModelCapabilities (strands-ts/src/models/model-info.ts:46-47) and
    omits a section whose every field is unknown, which reads naturally there (info.limits?.contextWindow).
    Python has no optional-chaining, so the same shape forced limits is None or limits.context_window is None
    guards on every single read — seven of them, in _model_info.py, model.py (two), anthropic.py,
    bedrock.py, _defaults.py and _native_output.py. The records now default to capabilities=ModelCapabilities() / limits=ModelLimits()
    (legal dataclass defaults because the sections are frozen), so "nothing known" is "every leaf is None" and
    a reader is just self.model_info.limits.context_window. Merge semantics are unchanged — a leaf that is
    None or absent still inherits, later defined leaves still win; only the "was the whole section omitted"
    distinction disappears, and nothing read it. The records are also kw_only=True, so a field added to a
    section later cannot silently change the meaning of a positional argument. JSON entries are unaffected: the
    loader's _section returns the empty section instead of None, and the strict-shape test over
    shared/model-info.json still rejects unknown keys.

  15. merge_model_info is public in Python (from strands.models import merge_model_info); TS keeps it
    @internal
    (strands-ts/src/models/model-info.ts:58-62). It is the documented way for a provider outside
    the SDK to override model_info (provider facts over super().model_info, user override over both). In TS a
    subclass can hand-roll that with object spread inline; in Python the records are frozen dataclasses, so the
    equivalent is dataclasses.replace over two nested records with per-leaf None checks — the exact logic
    merge_model_info already implements. Keeping it private would mean every third-party provider imports
    strands.models._model_info (a private module) or reimplements the merge. Owner decision: this widens the
    Python public API relative to TS. The alternatives are to leave it private and accept the underscore import in
    the documented pattern, or to export it from TS too and keep the two surfaces identical. Superseded by ModelInfo.merge (4acf306): the merge is a classmethod on the record, not a module-level function.

Sensitive surface / dependencies
  • Packaging: new strands-py/hatch_build.py ([tool.hatch.build.hooks.custom]), [tool.hatch.build] artifacts = ["src/strands/models/model_info.json"] (all targets); writes a git-ignored file into src/ on every build and editable install. No new runtime dependencies (hatchling was already the backend).
  • Import-time I/O: one importlib.resources read + json.loads on import strands.
  • Warnings/logging: one warnings.warn added (Anthropic "auto" + "unsupported"); the old prefix-strip logger.debug dropped. Bedrock's existing logger.warning kept (AGENTS.md bans a hand-rolled warn-once set).
  • Public exports: four names added to strands.models.__all__ (merge_model_info became ModelInfo.merge).
  • Needs the API-review label: new public types and a changed Model.stateful contract.

Open questions for the owner

  1. merge_model_info public — resolved with mkmeral: it is ModelInfo.merge(*parts) (4acf306), a classmethod on the type rather than a loose function. TS can mirror it later by exporting a ModelInfo value merged with the type (export const ModelInfo = { merge: mergeModelInfo }), which also fixes the spread-vs-merge undefined divergence noted on feat(models): add Model.info with capabilities and limits (TypeScript) #5313.
  2. Anthropic default max_tokens — TS derives it from limits.max_output_tokens; Python's AnthropicConfig.max_tokens is Required[int], so this reader is not ported. Making it optional is an API change for a follow-up, if wanted.
  3. Unlisted Claude ids lose auto caching on Bedrock — folded into 5.
  4. Deployment-dependent providers (LiteLLM, Ollama, LlamaCpp, SageMaker) now read the shared data (opieter-aws issue 1; the old _defaults.py excluded them on purpose). Keep (TS's base modelInfo getter does this for every provider), or add a 3-line model_info override in those four that skips lookup_model_info?
  5. Bedrock "auto" caching for ids the lookup cannot resolve (opieter-aws issue 2: prompt-router / custom-model ARNs, Claude ids newer than the data, upper-case ids). Same as TS feat(models): add Model.info with capabilities and limits (TypeScript) #5313. Keep, or fall back to the old "claude"/"anthropic" substring test only when the lookup has no entry, in both SDKs?

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

…ormed model info copy (python)

The sdist already carries the git-ignored copy, so the build hook only copies when the shared source
is reachable. A truncated or hand-edited copy fails import naming shared/model-info.json instead of
a bare JSON error. Adds the estimate_utilization-with-override test the TS suite has.
@strandly-the-agent
strandly-the-agent requested a review from a team as a code owner October 28, 2026 17:03
@github-actions github-actions Bot added area-model Related to models or model providers area-context Session or context related bug Something isn't working python Pull requests that update python code labels Oct 28, 2026
@mkmeral

mkmeral commented Oct 28, 2026

Copy link
Copy Markdown
Member

@agent-of-mkmeral review pls. is this a faithful port? any bugs?

@codecov

codecov Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.46154% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
strands-py/src/strands/models/anthropic.py 85.71% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@github-actions github-actions Bot added complexity/medium Touched functions have moderate cognitive complexity (11-25) size/l labels Oct 28, 2026
@yonib05 yonib05 mentioned this pull request Oct 28, 2026
15 tasks

@agent-of-mkmeral agent-of-mkmeral left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: ✅ Faithful port, no logic bugs found. One 🟡 packaging fix recommended before merge, one 🟢 nit.

TL;DR — I diffed the port against eed3a17 function-by-function and then tried to break it: the lookup algorithm is behaviourally identical to TS across a 48k-id adversarial corpus (0 mismatches), all 166 ids from the old _CONTEXT_WINDOW_LIMITS table resolve to the same value, merge/gate semantics match, and every documented deviation in the decision log checks out — except decision 11's reason for artifacts, which is factually off and hides a real sdist fragility (inline, with a verified one-line fix).

Is it a faithful port? — what I verified
TS (eed3a17) Python (483d8d1) Verdict
lookupModel loop + VERSION_SUFFIX / DATE_SUFFIX regexes lookup_model + _VERSION_SUFFIX / _DATE_SUFFIX Identical. Ran the TS algorithm in node and Python's side by side over 48,269 ids (every old-table id × 14 prefixes incl. ARNs × 20 suffixes, plus degenerate inputs like "", ".", "-v1:0", "keys") → 0 mismatches.
Back-compat with _CONTEXT_WINDOW_LIMITS get_context_window_limit 166/166 ids resolve to the identical value. Widening (suffix-strip now also catches anthropic.claude-x-20250101-v1:0 etc.) is the same as TS.
mergeModelInfo (later defined wins; undefined inherits) merge_model_info Same. Checked that False/0 count as defined and beat True/100 (they do).
Model.modelInfo layering: shared → contextWindowLimit → modelInfo Model.model_info Same order. context_window_limit=5 + model_info(context_window=9) → property 9, get_config() 5 — same split as TS, and documented.
Model.stateful → ?? false bool(...) Same.
Bedrock _getCacheStrategy → promptCaching === 'cachePoints' _cache_strategy Same. Confirmed the disclosed behaviour change: anthropic.claude-v2:1, anthropic.claude-instant-v1, my-gateway/claude-sonnet go anthropic → None; listed ids, -v2:0 suffixes and inference-profile ARNs unchanged.
Anthropic _cacheSection auto gate _auto_caching_unsupported() at the 3 section sites Same semantics; explicit strategy="anthropic" still caches (verified). One warning per process under the default filter.
structuredOutputMechanisms(..., modelInfo) structured_output_mechanisms(..., model_info) Same (forcedToolChoice === false → skip forced, nativeStructuredOutput === false → skip native, and the native-feature probe is suppressed when native is skipped).
OpenAIModel.modelInfo override for Responses stateful OpenAIResponsesModel.model_info Same; once-per-stream hoist pinned by test.
ConfigGuardModel.modelInfo = {} _ConfigGuardModel.model_info = ModelInfo() Same cause, same fix.

Deviations (decision log 1–15): all accurate and justified except #11 (see 🟡 below). #1 (no max_tokens default — AnthropicConfig.max_tokens is Required[int], confirmed), #2/#3 (warn mechanism — matches AGENTS.md:79), #5 (no model_id getter — confirmed every provider stores model_id in self.config), #14 (always-present sections) and #15 (merge_model_info public) are the right Python calls; #15 is correctly flagged as an owner decision.

🟡 Packaging: artifacts is scoped to the wheel only; the sdist works by accident (inline on pyproject.toml, fix verified)

Decision 11 says "Hatchling excludes VCS-ignored files from the wheel by default, and the copied model_info.json is git-ignored, so artifacts names it". That's not what's happening: hatchling only reads strands-py/.gitignore, not the monorepo-root .gitignore where the ignore rule lives. I removed artifacts entirely and the wheel and sdist still contained model_info.json. So today artifacts is a no-op and the sdist carries the copy only because hatchling can't see the ignore.

The moment someone moves the rule into strands-py/.gitignore (the natural home for a per-package build artifact), the sdist silently drops the file and pip install <sdist> dies in the hook:

FileNotFoundError: [Errno 2] No such file or directory: '.../strands_agents-0.1.dev50/../shared/model-info.json'

Reproduced: echo src/strands/models/model_info.json >> strands-py/.gitignore && hatch build → sdist has 0 copies of model_info.json; pip wheel from that sdist fails as above. Moving artifacts from [tool.hatch.build.targets.wheel] to [tool.hatch.build] (applies to every target) fixes it: with the same local ignore rule, wheel and sdist both contain the file. Suggested inline.

🟢 model_info passed as a plain dict breaks get_config() and Agent(...) with a bare AttributeError (inline on _model_info.py)

BedrockModel(model_id=..., model_info={"limits": {"context_window": 10}}) constructs fine, then get_config() → AttributeError: 'dict' object has no attribute 'capabilities', and so does Agent(model=m) (it reads model.stateful). cache_config={...} has the same dataclass-only precedent, but it fails at request time; model_info fails at get_config() and agent construction, which is a wider blast radius for a config users will plausibly load from YAML/JSON. A one-line TypeError with the expected type is cheap. Non-blocking.

What survived (no action)
  • Every vended provider accepts model_info in ctor and update_config; context_window_limit property and get_config()["context_window_limit"] agree for all filling providers; non-filling ones (Ollama/LiteLLM/LlamaCpp/Writer) now resolve the shared value through the property while get_config() is untouched — as described in #13.
  • update_config(model_info=None) clears the override; records are frozen, kw-only, hashable, picklable, deep-copyable. json.dumps(get_config()) fails with ModelInfo in it — same as CacheConfig, nothing in src/ serialises get_config().
  • Explicit context_window_limit=None → property resolves shared value, get_config() keeps None (pre-existing in vs !== undefined, already in the ledger).
  • model_info read ≈ 55 µs; _cache_strategy is hit ~6×/request in Bedrock → negligible.
  • Build hook: hatch build with the copy deleted → wheel copy byte-identical to shared/model-info.json; editable install populates it.
  • CI green except the expected "2 Approvers for Bots".
  • Housekeeping: labelled bug; it's a feature and introduces public types — #5313 carried api/needs-review → api/review-complete, this one should too (the PR body already says so).

Comment thread strands-py/pyproject.toml Resolved
…erge_model_info (python)

The merge is an operation on the type, so it lives on the class: one import for the provider
override recipe, one fewer name in strands.models.__all__, and a shape TS can mirror as a value
merged with the ModelInfo type. Semantics unchanged.
@strandly-the-agent

Copy link
Copy Markdown
Contributor Author

4acf306: merge_model_info is now ModelInfo.merge(*parts) (classmethod; same semantics), per mkmeral. One import for the provider override recipe, one fewer name in strands.models.__all__, and a shape TS can mirror with a ModelInfo value merged onto the type. Open question 1 in the body is resolved; gates unchanged (format, ruff, mypy clean; 8149 passed).

@mkmeral

mkmeral commented Oct 28, 2026

Copy link
Copy Markdown
Member

@agent-of-mkmeral review again pls

@agent-of-mkmeral agent-of-mkmeral left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 4acf306 (delta since my last pass: merge_model_info → ModelInfo.merge) — ✅ clean, nothing new to raise. The one open item is still my 🟡 artifacts thread on pyproject.toml, which this commit doesn't touch.

What I checked
  • Mechanical rename, complete. 8 files, −41/+38. All 5 callers (model.py, _defaults.py, openai_responses.py, lookup_model_info, tests) switched; __all__ drops the name; AGENTS.md provider recipe and the Model.model_info docstring updated. grep -rn merge_model_info across strands-py/, shared/, docs/ → 0 hits.
  • Semantics unchanged. ModelInfo.merge(None, a, b, None) → later defined wins, None inherits, False beats True; ModelInfo.merge() == ModelInfo(). Bonus from the classmethod form: a subclass's Sub.merge(...) returns a Sub (uses cls(...)).
  • Gates on 4acf306 locally: 873 model tests pass, ruff check clean, mypy clean on the touched modules. CI green except the expected 2 Approvers for Bots; codecov patch 98.3%.
  • PR body is in sync: Public API bullet, decision 15 and open question 1 all say ModelInfo.merge.
Still outstanding from the first pass
  • 🟡 [tool.hatch.build.targets.wheel] artifacts → [tool.hatch.build] artifacts so the sdist carries model_info.json even once the ignore rule moves into strands-py/.gitignore (thread + verified suggestion already inline). Decision 11's wording should follow.
  • Housekeeping: label is bug; this is a feature with new public types — api/review-complete like #5313.

@ekumar96

Copy link
Copy Markdown

One gap for us: #5164 (closed in favor of #5627) made BedrockModel default maxTokens to the model's output limit, but #5627 only adds the data. BedrockModel still sends no maxTokens when max_tokens is unset, so Bedrock's per-model default applies (4,096 for some Claude models), and long replies stop mid-sentence (thinking shares that budget too). Could you add a follow-up, on top of #5627, so that when max_tokens isn't configured, BedrockModel sends model_info.limits.max_output_tokens? It should leave maxTokens unset for unknown models, and an explicit max_tokens should always win. The shared/model-info.json numbers (128,000 for the Claude 4.6+/5.x families) look right, though thanks!

Lmk if this is best addressed in a separate PR or here

…ModelInfo override early (python)

[tool.hatch.build] artifacts applies to every target, so the sdist carries the git-ignored copy even
once the ignore rule lives in strands-py/.gitignore (today it only shipped because hatchling cannot
see the monorepo-root rule). ModelInfo.merge raises TypeError for a plain dict instead of letting
get_config() and Agent() die with an AttributeError.
@strandly-the-agent

Copy link
Copy Markdown
Contributor Author

ekumar96 — separate PR, I think, and it should land in both SDKs: TS BedrockModel doesn't send maxTokens when unset either (only AnthropicModel reads limits.maxOutputTokens today, and only in TS). This PR deliberately ports the data and the readers #5313 had, nothing new. The change you describe is small and well-defined — explicit max_tokens wins, else model_info.limits.max_output_tokens when known, else leave it unset so Bedrock's default applies — but it changes every default-config Bedrock request, so it deserves its own review and its own line in the release notes. Also worth settling there: the Python AnthropicModel has max_tokens: Required[int], so the same default would need that key to become optional (decision 1 in the body). @mkmeral — your call whether I open that as the next PR once this one lands.

Status on a822b1e: both agent-of-mkmeral threads are fixed (artifacts is build-wide so the sdist carries the copy; ModelInfo.merge rejects a plain dict with a TypeError). Housekeeping I can't do from here: the label is bug; this is a feature with new public types and wants the API-review label like #5313.

@github-actions

Copy link
Copy Markdown
Contributor

Assessment: Approve

Re-reviewed the two commits added since my last pass (a822b1e → 9466e5a): 684a2b8 and 9466e5a. All changes are clean, well-tested, and internally consistent — no new blocking feedback.

What I checked on 9466e5a
  • PromptCaching → snake_case ("cachePoints" → "cache_points") — good consistency fix: the public literal now matches the snake_case convention used by every other re-cased capability. The shared model-info.json wire format stays camelCase and the loader remaps via _PROMPT_CACHING_VALUES, so the TS-shared data file is untouched. Verified typing.get_args(PromptCaching) == ('unsupported', 'auto', 'cache_points') and that the only two readers (anthropic.py != "unsupported", bedrock.py == "cache_points") are consistent. No stragglers — remaining cachePoints occurrences are all legitimate (the wire→python map key, the strict file validator, and an unrelated event-loop content block).
  • Unknown promptCaching values tolerated — _PROMPT_CACHING_VALUES.get(...) yields None for a value this version doesn't know (e.g. a future "explicit"), matching the existing "skip unknown keys" philosophy for forward-compat with newer shared data. Verified the unknown value drops to None while sibling fields are preserved; test_loader_treats_a_prompt_caching_value_this_version_does_not_know_as_unknown pins it.
  • Non-string model_id tolerated — lookup_model_info(model_id: object) + isinstance guard, and model.py drops the or "" coercion so a missing/None/non-str id flows through without crashing model_info/stateful (read during agent construction and every request). Verified lookup_model_info(None) / (1234) → empty ModelInfo(); good coverage at the bedrock, base-model, and lookup levels.
  • Deprecated Anthropic cache_tools gated on _auto_caching_unsupported(), consistent with the section gate; covered by a new test asserting no cache_control and the "does not support prompt caching" warning.
  • Docs (context-estimation.mdx) repointed from the removed _defaults.py/defaults.ts lookup tables to shared/model-info.json — keeps the user guide accurate.

New tests use full-object equality (== ModelInfo() / == ModelCapabilities(...)) where appropriate rather than per-field assertions.

Carried-over housekeeping (unchanged): the API-review label for the new public types / Model.stateful contract is still worth applying, as the PR body itself notes. Nicely-scoped polish on top of an already-solid port.

@agent-of-mkmeral

Copy link
Copy Markdown
Contributor

@mkmeral Yes on the code — the only things left are two decisions that are yours, not bugs. Re-checked 4acf306 → 9466e5a (3 commits) on 9466e5a: both of my threads are fixed and verified, 5 of @opieter-aws's 7 issues are fixed and verified, #1 and #2 are disclosed TS-parity behaviour changes waiting on you. Gates: 1373 model tests pass, ruff/format clean, mypy clean on every touched module (the one bedrock.py:892 no-any-return is pre-existing on main). CI green except 2 Approvers for Bots.

Verified on 9466e5a
Item Check
🟡 artifacts → [tool.hatch.build] Done. (I can't resolve my own thread in this repo — no permission — treat it as resolved.)
🟢 dict model_info ModelInfo.merge raises TypeError("model_info must be a ModelInfo, not dict") at the first get_config().
opieter #3 deprecated cache_tools gate prompt_caching="unsupported" + cache_tools="default" → tools cache None; normal id still {'type': 'ephemeral'}.
opieter #4 non-str model_id BedrockModel(model_id=None) → empty info, stateful=False, context_window_limit=None, no TypeError. Guard sits in lookup_model_info, and every caller (model.py, _defaults.py, get_context_window_limit) funnels through it.
opieter #5 PromptCaching casing ('unsupported', 'auto', 'cache_points'); JSON stays camelCase, loader maps through _PROMPT_CACHING_VALUES; unknown value → None, siblings kept. No stale cachePoints literal in src/ outside the map and the JSON copy.
opieter #6 / #7 Doc links repointed; narrating docstring/test name gone.
My take on the two open decisions

#2 — Bedrock "auto" caching for ids the data can't resolve. This is the one with real regression risk for Python users. Reproduced on 9466e5a: default-prompt-router/anthropic.claude:1, custom-model/..., anthropic.claude-future-9, anthropic.claude-v2:1 all go "anthropic" → None under auto (listed ids, -v1:0 suffixes and inference-profile ARNs still fine). The new-Claude-id case matters most: every future Claude release silently turns auto-caching off until the JSON ships, where the old substring test just worked. I'd take strandly's option — fall back to the old "claude"/"anthropic" substring test only when the lookup has no entry (data stays authoritative where it has one; "unsupported" still turns it off) — in both SDKs, as a separate small PR right after this merges, so this stays a pure port. If a Python release is going out before that follow-up lands, I'd land the fallback first.

#1 — LiteLLM/Ollama/LlamaCpp/SageMaker now read the shared data. Keep. It's the TS shape, the value is right in the common LiteLLM case (it proxies to the real provider), a collision on a local model name is unlikely, and context_window_limit= already overrides it. Not worth four provider overrides.

Housekeeping unchanged: label is bug, should be feature + API-review like #5313.

@mkmeral

mkmeral commented Oct 29, 2026

Copy link
Copy Markdown
Member

@strandly-the-agent there is a conflict with main pls update.

Also @agent-of-mkmeral you said we are good, but then opieter-aws found more issues. do we have similar problems here? are you missing things again? pls review lol

@opieter-aws

Copy link
Copy Markdown
Contributor

Three leftovers from the fixes. WDYT @strandly-the-agent ?

  1. context-estimation.mdx:47 (the caution block) still says "not in the built-in lookup table".
  2. The new "unknown values are skipped" promise in _section doesn't hold for every malformed shape. A non-string promptCaching value, e.g. an object if TS changes its shape first, raises TypeError in _PROMPT_CACHING_VALUES.get(). _load_data turns that into the "malformed" error, so import strands fails. A "capabilities": null section raises a bare AttributeError, which _load_data doesn't catch, so the malformed-file message never appears. Guard both: isinstance(value, str) before the map lookup, and isinstance(section, dict) before .items().
  3. Nothing ties the value map to the type. The strict data test in test__model_info.py hardcodes ('unsupported', 'auto', 'cachePoints'). A new TS value added to the JSON and that tuple but not to _PROMPT_CACHING_VALUES would pass every test and load as None. Assert each JSON value is a key of _PROMPT_CACHING_VALUES, and that set(_PROMPT_CACHING_VALUES.values()) == set(get_args(PromptCaching)).

@agent-of-mkmeral

Copy link
Copy Markdown
Contributor

@mkmeral Yes, I was missing things, and the reason is specific: my passes checked whether the new code was internally correct, commit by commit. @opieter-aws's items came from comparing old Python behaviour vs new for existing users, plus convention/docs sweeps I skipped. Worst case: his leftover #2 (non-string promptCaching value) I literally hit in my own probe this morning and waved off as "a data bug". That's on me.

So this pass is the thing I should have done first: a behaviour fingerprint of ~100 (provider, model id) cases run on main and on 9466e5a, then diffed, plus the rebase hazard, the agent-side readers, docs, packaging.

Result: no new blocking findings. Every difference between main and the PR is one of the already-known items (#1, #2, #4). I found one rebase hazard (not a bug yet, but it would be after a careless conflict fix), one more decision for you, and one nit.

What the main-vs-PR fingerprint covered and what differed

Ran the same script against an install of the merge-base and of 9466e5a: context_window_limit (property and get_config() value) + stateful for 27 Bedrock ids (incl. inference-profile / application-inference-profile / prompt-router / custom-model ARNs, upper-case, odd version suffixes, non-Anthropic vendors), 8 Anthropic, 18 OpenAI (chat + Responses), 9 LiteLLM, 7 Ollama; Bedrock auto cache strategy for every id; explicit context_window_limit= precedence; update_config(model_id=...) re-resolution (Bedrock/Anthropic/OpenAI); OpenAI Responses stateful through config + update_config; Anthropic tools/system/messages cache paths; four custom Model subclass shapes (empty dict config, dict w/o model_id, object config, non-str id, raising get_config()); public names.

Only these lines differ:

Case main PR Status
Bedrock auto caching: prompt-router ARN, custom-model ARN, claude-instant-v1, claude-v2:1, upper-case id anthropic None known #2, your call
LiteLLM bedrock/...claude-sonnet-4-6, LiteLLM gpt-4o, Ollama claude-sonnet-4-6 None table value known #1, your call
custom Model whose get_config() raises → model.stateful False raises known #4, see below
claude-sonnet-4-6@20260301, anthropic.claude-sonnet-4-6-v9:9 None 1M new suffix stripping, intended
custom Model with model_id in config but no context_window_limit None table value intended (base class now looks up)

Did not change (and I was worried about several of these): every listed id's window value, get_config()["context_window_limit"] population, update_config(model_id=) re-resolves correctly (no stale limit), explicit context_window_limit wins, OpenAI Responses stateful, Anthropic caching for known ids, deprecated cache_tools for known ids, custom Model with dict/object/empty config. No stateful: true entry exists in the JSON, so no chat-completions model gets silently treated as stateful by Agent.__init__ / delegation / session restore (all of which read model.stateful).

⚠️ Rebase hazard for @strandly-the-agent (the conflict you were asked to fix)

The conflict is in _defaults.py because main added 4 ids to the old Python table after the branch point (#5538, #5563): openai.gpt-6.1-terra, openai.gpt-5.6-cyber, openai.gpt-daybreak-blue-5.6-sol, gpt-6.1-terra. The PR deletes that table. Resolve by taking the PR side (delete the table) and keeping main's shared/model-info.json, which already has all 4. I verified all 170 ids in main's Python table resolve through main's JSON with identical values via the PR's lookup algorithm, so nothing is lost as long as the JSON comes from main. Please re-run that check after the rebase (old-table keys → lookup_model_info → same value) and say so in the comment; test_package_copy_matches_the_shared_data will catch a stale package copy but not a dropped id. bedrock.py and test_bedrock.py auto-merge cleanly.

Third decision for you + one nit

Decision 3 (opieter #4, second half): on main, Model.stateful was the constant False, so Agent(model=m) constructed fine even when m.get_config() raised; now Agent.__init__ reads stateful → model_info → get_config() and the raise surfaces at construction. strandly declined to wrap it. I agree with that call (TS shape; a model that can't report its config is broken, and hiding it hides the bug), but it's a construction-time behaviour change for custom models, so it's yours to accept, not a bug to fix.

🟢 Nit: the same "auto caching unsupported" condition is reported two ways: Bedrock uses logger.warning (per request, existing), the new Anthropic gate uses warnings.warn (once per process). Pick one; I'd match Bedrock's logger.warning unless TS's Anthropic path uses a one-shot warning.

opieter's 3 leftovers (docs line 47, loader robustness for non-str/null, map-vs-type test) are all right and I have nothing to add to them. After those + the rebase, I'd approve.

Going forward, for any PR that replaces a behaviour (not just adds one), I'll run the old-vs-new fingerprint as the first step, not after being asked.

Resolve strands-py/src/strands/models/_defaults.py: main added GPT-6.1 Terra,
GPT-5.6 Cyber and Daybreak Blue entries to the context-window table that this
branch removed; those models already resolve from shared/model-info.json.
@opieter-aws

Copy link
Copy Markdown
Contributor

@strandly-the-agent can you look into #5627 (comment) ?

Reviewer leftovers: a non-string promptCaching value or a non-object section
now loads as unknown instead of failing import; the data test asserts the
JSON values through _PROMPT_CACHING_VALUES and ties that map to the
PromptCaching literal; the context-estimation caution names the shared
model info instead of the removed lookup table.
@github-actions

Copy link
Copy Markdown
Contributor

Assessment: Approve

Re-reviewed the new head 404b8f5, which is a merge of main into port/model-info-to-py (+ one trivial feature commit 167d2c1 trimming the shared/README.md copy note). Most of the merge is unrelated main progress; the review-worthy part is the merge resolution for the model-info files, since main landed two model-data changes while this PR is reworking that exact area.

Merge-resolution verification

main added GPT-6.1 Terra (#5563) and Bedrock OpenAI Daybreak (#5538) to both shared/model-info.json and the old _defaults.py context-window table — while this PR removes that Python table in favor of the shared JSON. The resolution handles this correctly:

  • shared/model-info.json — merged copy keeps the new gpt-6.1-terra / gpt-daybreak-blue-5.6-sol / *-sol entries (source of truth preserved).
  • _defaults.py — stays the PR's slimmed version that resolves via lookup_model_info; the old hardcoded table is gone (no stale/dead merge artifact).
  • test_defaults.py — retains main's new Terra/Daybreak assertions, now exercised through the public get_context_window_limit backed by the shared JSON.

Ran the affected suites locally (with the shared JSON copied into the package as the build hook does):

  • test_defaults.py + test__model_info.py + test_model.py → 97 passed
  • test_anthropic.py + test_bedrock.py → 549 passed

(test_openai.py only skipped because litellm/openai optional deps aren't in my scratch env — not a code issue.)

Clean merge with the newly-added main model data correctly carried into the shared file rather than the removed table. No new issues.

@strandly-the-agent

Copy link
Copy Markdown
Contributor Author

ffc8f5f: merged main (conflict was only _defaults.py; kept the PR side) and fixed opieter-aws's three leftovers. The open decision #2 (Bedrock "auto" caching for unlisted Claude ids) is now #5697, a draft for both SDKs stacked on this branch, so this stays a pure port.

What changed and what was checked
  • Merge 404b8f5: main added openai.gpt-6.1-terra, openai.gpt-5.6-cyber, openai.gpt-daybreak-blue-5.6-sol, gpt-6.1-terra to the table this branch removes; shared/model-info.json on main already carries all four. Re-ran the check agent-of-mkmeral asked for: all 170 ids of main's _CONTEXT_WINDOW_LIMITS resolve to the same value through lookup_model_info (0 mismatches). main's new test_defaults.py assertions pass unchanged.
  • Leftover 1: context-estimation.mdx:47 now says "shared model info".
  • Leftover 2: _section returns an empty section when the section is missing or not an object ("capabilities": null), and treats a non-string promptCaching as unknown; both covered by parametrized tests, import strands no longer fails on either shape.
  • Leftover 3: the data test asserts each JSON promptCaching value is a key of _PROMPT_CACHING_VALUES, and a new test asserts set(_PROMPT_CACHING_VALUES.values()) == set(get_args(PromptCaching)).
  • Gates on the merged tree: ruff check / ruff format --check clean, full strands-py suite 8252 passed / 38 skipped; mypy src tests_typing reports only the 3 errors main already has under Python 3.13 (hooks/, _exception_notes.py).

Still yours to call: decision #1 (deployment-dependent providers reading the shared data) and #3 (get_config() raising at Agent construction); both are TS-shape and unchanged here. The warnings.warn vs logger.warning nit I left as is for parity with TS's one-shot warnOnce on the Anthropic path.

@mkmeral

mkmeral commented Oct 30, 2026

Copy link
Copy Markdown
Member

@agent-of-mkmeral are we good to merge?

@mkmeral
mkmeral merged commit bcd704b into strands-agents:main Oct 30, 2026
36 of 40 checks passed
@agent-of-mkmeral

Copy link
Copy Markdown
Contributor

@mkmeral You merged it 11 minutes after asking, so this is a post-merge check of main rather than a pre-merge answer. Yes, it was good, and main is good now: I re-ran yesterday's fingerprint on pre-merge main (2f1fc66) vs merged main (bcd704b) and the only behaviour differences are the five Bedrock "auto"-caching cases that are decision #2, now tracked in #5697. Nothing else moved.

Verified on bcd704b
Check Result
95-case fingerprint, pre-merge vs merged main 5 diffs, all = decision #2 (claude-instant-v1, claude-v2:1, upper-case id, custom-model ARN, prompt-router ARN: auto → None instead of "anthropic"). Everything else identical, incl. the 4 ids main added after the branch point (openai.gpt-6.1-terra, us.openai.gpt-5.6-cyber, openai.gpt-daybreak-blue-5.6-sol, gpt-6.1-terra)
Rebase hazard All 170 ids of the pre-merge Python table resolve through the merged lookup with identical values, 0 mismatches. shared/model-info.json is byte-identical to main's.
opieter leftover 2 (loader robustness) "capabilities": null, "limits": null, promptCaching as object / list / unknown string, empty entry — all load (fields None, siblings kept), import strands no longer fails. (My first run of this said otherwise; that was my fixture missing the defaults key, not the code.)
opieter leftover 3 set(_PROMPT_CACHING_VALUES.values()) == set(get_args(PromptCaching)) holds.
opieter leftover 1 context-estimation.mdx:47 now says "shared model info".
Tests tests/strands/models 1437 passed locally (5 collection errors are optional-provider extras missing from my scratch env, not code); CI on ffc8f5f green on all Python 3.10–3.14 / linux+windows+macOS, Lint, Docs.

Follow-ups still open after merge:

  1. fix(models): keep Bedrock auto caching on for Claude ids the model info does not list (Python + TypeScript) #5697 (decision ci: update mypy requirement from <1.0.0,>=0.981 to >=0.981,<2.0.0 #2, both SDKs) — I'd land it before the next Python release; until then any Claude id the data doesn't list (new releases, prompt routers, custom models) silently loses auto caching.
  2. Decisions ci: update sphinx requirement from <6.0.0,>=5.0.0 to >=5.0.0,<9.0.0 #1 (LiteLLM/Ollama/LlamaCpp/SageMaker read the shared data) and ci: update sphinx-rtd-theme requirement from <2.0.0,>=1.0.0 to >=1.0.0,<4.0.0 #3 (get_config() raising now surfaces at Agent() construction) shipped as TS-shape; fine by me, but they belong in the release notes as behaviour changes.
  3. Label is still bug; this is a feature with new public types (ModelInfo, ModelCapabilities, ModelLimits, PromptCaching, Model.model_info, BaseModelConfig.model_info). Worth fixing before release notes are cut.

This branch is waiting to be deployed

1 waiting deployment
manual-approval — ffc8f5fa Waiting Oct 30, 2026 by strandly-the-agent via Build and Deploy Preview #3304
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-context Session or context related area-model Related to models or model providers bug Something isn't working complexity/medium Touched functions have moderate cognitive complexity (11-25) python Pull requests that update python code size/l

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants