Repository navigation
feat(models): add Model.model_info with capabilities and limits (Python) - #5627
Conversation
…roviders (python)
…window drift check (python)
…er's stateful check (python)
…ons; public merge_model_info (python)
…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.
…ize models __all__ (python)
|
@agent-of-mkmeral review pls. is this a faithful port? any bugs? |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
agent-of-mkmeral
left a comment
There was a problem hiding this comment.
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_infoin ctor andupdate_config;context_window_limitproperty andget_config()["context_window_limit"]agree for all filling providers; non-filling ones (Ollama/LiteLLM/LlamaCpp/Writer) now resolve the shared value through the property whileget_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 withModelInfoin it — same asCacheConfig, nothing insrc/serialisesget_config().- Explicit
context_window_limit=None→ property resolves shared value,get_config()keepsNone(pre-existinginvs!== undefined, already in the ledger). model_inforead ≈ 55 µs;_cache_strategyis hit ~6×/request in Bedrock → negligible.- Build hook:
hatch buildwith the copy deleted → wheel copy byte-identical toshared/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 carriedapi/needs-review→api/review-complete, this one should too (the PR body already says so).
…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.
|
|
|
@agent-of-mkmeral review again pls |
agent-of-mkmeral
left a comment
There was a problem hiding this comment.
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.mdprovider recipe and theModel.model_infodocstring updated.grep -rn merge_model_infoacrossstrands-py/,shared/,docs/→ 0 hits. - Semantics unchanged.
ModelInfo.merge(None, a, b, None)→ later defined wins,Noneinherits,FalsebeatsTrue;ModelInfo.merge() == ModelInfo(). Bonus from the classmethod form: a subclass'sSub.merge(...)returns aSub(usescls(...)). - Gates on
4acf306locally: 873 model tests pass,ruff checkclean,mypyclean 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] artifactsso the sdist carriesmodel_info.jsoneven once the ignore rule moves intostrands-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-completelike #5313.
|
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.
|
Status on |
|
Assessment: Approve Re-reviewed the two commits added since my last pass ( What I checked on 9466e5a
New tests use full-object equality ( Carried-over housekeeping (unchanged): the API-review label for the new public types / |
|
@mkmeral Yes on the code — the only things left are two decisions that are yours, not bugs. Re-checked Verified on 9466e5a
My take on the two open decisions#2 — Bedrock #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 Housekeeping unchanged: label is |
|
@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 |
|
Three leftovers from the fixes. WDYT @strandly-the-agent ?
|
|
@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 So this pass is the thing I should have done first: a behaviour fingerprint of ~100 (provider, model id) cases run on Result: no new blocking findings. Every difference between What the main-vs-PR fingerprint covered and what differedRan the same script against an install of the merge-base and of Only these lines differ:
Did not change (and I was worried about several of these): every listed id's window value,
|
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.
|
@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.
|
Assessment: Approve Re-reviewed the new head Merge-resolution verification
Ran the affected suites locally (with the shared JSON copied into the package as the build hook does):
( Clean merge with the newly-added |
|
What changed and what was checked
Still yours to call: decision #1 (deployment-dependent providers reading the shared data) and #3 ( |
|
@agent-of-mkmeral are we good to merge? |
|
@mkmeral You merged it 11 minutes after asking, so this is a post-merge check of Verified on bcd704b
Follow-ups still open after merge:
|
…on) (strands-agents#5627) Co-authored-by: strandly-the-agent <strandly-the-agent@users.noreply.github.com>
Requested by: @mkmeral — "can you port this to python?" (on #5313)
Human Overview
Description
Python port of #5313 (
eed3a17). EveryModelgetsmodel_info: ModelInfo, resolved on each read, later layers winning: the sharedshared/model-info.jsonentry for the model id → thecontext_window_limitconfig value → themodel_infoconfig override.Model.statefulandModel.context_window_limitderive from it, so config, record andstatefulcannot disagree. No provider hook: a provider that knows better than the shared file overrides the property asModelInfo.merge(super().model_info, <its facts>, self.get_config().get("model_info"));OpenAIResponsesModeldoes so forstateful.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_idsubstring test), the structured-output ladder (forced_tool_choice is False/native_structured_output is Falseskip a mechanism before any request), and a new Anthropic gate (prompt_caching == "unsupported"turns"auto"off). The hard-coded_CONTEXT_WINDOW_LIMITStable is gone;shared/model-info.jsonis 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,Noneinherits). Frozen, keyword-only dataclasses; sections are always present (ModelInfo().limits.context_windowisNone, no guards needed).BaseModelConfig.model_info: ModelInfo | None;Model.model_info(property);Model.statefulnow derived.cachePoints→"cache_points"), per the rootAGENTS.mdcross-SDK rule; the JSON stays camelCase.Behavior changes
"auto"(listed ids and resolvable ARNs unchanged; explicitstrategy="anthropic"still forces). Same as TS. Affected:anthropic.claude-v2*,claude-instant-v1, gateway ids containingclaude,provisioned-model/*ARNs. Fix, if wanted, is entries inshared/model-info.json.context_window_limitnow resolves for providers that never calledresolve_config_metadata(Ollama, LiteLLM, SageMaker, …) and formodel_info-overridden ids.import strandsreads the 16 KB JSON copy once; a missing or malformed copy fails with a message namingshared/model-info.jsonand 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
9466e5afromstrands-py/:ruff format --check src tests→ 682 files already formatted;ruff check src tests→ All checks passed;mypyon the touched modules → Success;pytest tests/strands→ 8155 passed, 38 skipped. Earlier ona822b1e:mypy ./src ./tests_typing→ Success: no issues found in 367 source files;hatch buildwith the JSON copy deleted first → wheel'sstrands/models/model_info.jsonbyte-identical toshared/model-info.json; sdist →pip wheel .outside the monorepo → same. Back-compat probe: every one of the 166 ids in the old_CONTEXT_WINDOW_LIMITSresolves to the identical value (0 mismatches);get_config()["context_window_limit"],context_window_limitandstatefulagree per provider. Mutation check: 7 source mutants, all caught.tests_integ/models/test_model_context_windows.pyrepointed at the shared file (62 cases collect; needs live credentials to run).hatch run prepare(format + lint + mypy + unit tests, mypy with--cache-diroff NFS)Additional Details
Traceability matrix (source test → target test)
strands-ts/src/models/__tests__/model-info.test.ts→tests/strands/models/test__model_info.pydescribe mergeModelInfo)returns the base when nothing else is giventest_model_info_merge_returns_the_base_when_nothing_else_is_giveninherits fields a later record leaves out or sets to undefinedtest_model_info_merge_inherits_fields_a_later_record_leaves_out_or_sets_to_nonelets a later record win over an earlier onetest_model_info_merge_lets_a_later_record_win_over_an_earlier_oneleaves out sections with nothing knowntest_model_info_merge_returns_empty_sections_when_nothing_is_knownreturns a frozen recordtest_model_info_merge_returns_a_frozen_record(assertsAttributeErroron assignment, thefrozen=Trueanalog ofObject.isFrozen)strands-ts/src/models/__tests__/model-info-lookup.test.ts→tests/strands/models/test__model_info.pydescribe shared model info data)resolves every spelling of a model to its one entrytest_lookup_model_resolves_every_spelling_of_a_model_to_its_one_entryresolves a dated snapshot to the undated modeltest_lookup_model_resolves_a_dated_snapshot_to_the_undated_modeldoes not read inherited object keystest_lookup_model_does_not_read_inherited_object_keys(probeskeys/x.items, the Python analog oftoString/constructor)ignores a trailing Bedrock version suffixtest_lookup_model_ignores_a_trailing_bedrock_version_suffixreturns undefined for unknown idstest_lookup_model_returns_none_for_unknown_idsreturns the entry facts and nothing for unknown idstest_lookup_model_info_returns_the_entry_facts_and_nothing_for_unknown_idskeeps getContextWindowLimit working for ids the Python table already knewtest_get_context_window_limit_keeps_working_for_ids_the_table_already_knewexposes the context window default from the datatest_default_context_window_limit_comes_from_the_datahas only the fields ModelInfo defines, with the right types, in every entrytest_shared_data_has_only_the_fields_model_info_defines_with_the_right_types_in_every_entrystrands-ts/src/models/__tests__/model-info.test.node.ts→tests/strands/models/test__model_info.pymatches shared/model-info.json at the repository roottest_package_copy_matches_the_shared_datastrands-ts/src/models/__tests__/model.test.ts→tests/strands/models/test_model.pydescribe info)class TestModelInfo)is all unknown for a provider with no model id, and unknown stateful reads as falsetest_is_all_unknown_without_a_model_id_and_unknown_stateful_reads_as_falseresolves facts from the shared model info for any provider by modelIdtest_resolves_facts_from_the_shared_model_info_by_model_idlets a provider put its facts over the shared model info, with the user override over bothtest_lets_a_provider_put_its_facts_over_the_shared_model_info_with_the_user_override_over_bothlets the info override set stateful, and the stateful getter reads ittest_lets_the_override_set_stateful_and_the_stateful_property_reads_itlets the info override win over contextWindowLimittest_lets_the_override_win_over_context_window_limittakes the context window from the configtest_takes_the_context_window_from_the_configreads stateful from what a provider puts in the recordtest_reads_stateful_from_what_a_provider_puts_in_the_recordreflects config changes on the next readtest_reflects_config_changes_on_the_next_readestimateUtilization › uses the context window from info, including an info overrideTestModelInfo::test_lets_the_override_win_over_context_window_limit+ the pre-existingTestEstimateUtilizationsuite, which now reads throughmodel_info(estimate_utilization→context_window_limit→model_info.limits.context_window)strands-ts/src/models/__tests__/bedrock.test.ts→tests/strands/models/test_bedrock.pyauto-populates contextWindowLimit from the modelInfo override over the shared valuetest__init__auto_populates_context_window_limit_from_the_model_info_overrideinfo › reads cache points for Claude model ids from the shared model infotest_model_info_reads_cache_points_for_claude_from_the_shared_model_infoinfo › leaves caching unknown for other model idstest_model_info_leaves_caching_unknown_for_other_model_idsinfo › does not auto-cache a Claude id the shared model info does not listtest_cache_strategy_auto_does_not_cache_a_claude_id_the_shared_model_info_does_not_listinfo › injects auto cache points on a model the info override says takes themtest_cache_strategy_auto_maps_to_anthropic_when_the_model_info_override_says_cache_pointsinfo › skips auto cache points on a Claude model the info override marks unsupportedtest_cache_strategy_auto_is_none_when_the_model_info_override_marks_caching_unsupportedstarts at native output for a model the shared model info says cannot be forcedtest_stream_structured_output_starts_at_native_when_the_model_info_forbids_forcingskips native output when the info override says the model lacks ittest_stream_structured_output_skips_native_when_the_model_info_override_lacks_itwarns through warnOnce when auto finds no caching supportlogger.warningrather than a hand-rolled warn-once (decision 2); the existinglogger.warningcall site is unchanged by this port, and no Python test pinned it before or after. The detection change it guards is covered bytest_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 fromconsole.warnspy towarnOnce)modelId: 'anthropic.claude-test-model'→'anthropic.claude-sonnet-4-6'(2 request-shape tests)m1and 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.pyinfo › reads cache points from the shared model infotest_model_info_reads_cache_points_from_the_shared_model_infoinfo › re-resolves the default maxTokens when modelId changestest_model_info_exposes_the_output_ceiling_from_the_shared_model_info— assertsmodel_info.limits.max_output_tokensre-resolves onupdate_config(model_id=...), notget_config()["max_tokens"](gap 1: Python'smax_tokensisRequired[int], so there is no default to re-resolve)info › defaults maxTokens to an info override of the output ceilingtest_model_info_override_wins_over_the_shared_output_ceiling— same narrowing as the row aboveinfo › defaults maxTokens to the model output ceiling from the shared model infomax_tokensdefault to derive, so there is no behavior to assert.cache › is off under auto when the modelInfo override says caching is unsupportedTestPromptCaching::test_auto_is_off_when_the_model_info_override_says_caching_is_unsupportedcache › caches regardless of the override when the anthropic strategy is explicitTestPromptCaching::test_caches_regardless_of_the_override_when_the_anthropic_strategy_is_explicitgetConfigmaxTokens expectation changes (64_000→128_000, 4 tests)max_tokensstays caller-supplied, so its equivalent tests are unchanged.strands-ts/src/models/__tests__/native-output.test.ts→tests/strands/models/test__native_output.pyskips 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 recordsstrands-ts/src/models/openai/__tests__/responses.test.ts→tests/strands/models/test_openai_responses.pyresolves stateful once per stream, not once per eventtest_stream_resolves_stateful_once_per_stream_not_once_per_event(countsget_configcalls; 1 call for a 52-event stream)statefulvia themodelInfooverridetest_stateful(extended with amodel_info.capabilities.statefulassertion) andtest_stateful_model_info_override_wins_over_the_config_optionstrands-ts/src/models/routing/__tests__/classifier-strategy.test.ts→tests/strands/models/routing/test_classifier_strategy.pyConfigGuardModelgainsoverride get modelInfo() { return {} }_ConfigGuardModel.model_inforeturnsModelInfo(), with the same one-line comment. Ported after the full-suite run failed ontest_select_uses_only_explicit_candidate_evidence: the router's_reject_stateful(src/strands/models/routing/router.py:538) readscandidate.model.stateful, which now resolves throughget_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.pydoes not strip unknown prefixes→strips leading segments one at a time, like the Python SDK, so unknown prefixes and ARNs resolvetest_strips_any_prefix_as_fallbackand the ARN case intest_known_bedrock_openaialready assert it, unchanged and passing.strands-ts/test/integ/models/context-windows.test.ts→tests_integ/models/test_model_context_windows.pyCONTEXT_WINDOW_LIMITS→model-info.jsonstrands.models._defaults._CONTEXT_WINDOW_LIMITStoshared/model-info.json(62 cases collected)Source tests with no Python analog by design
strands-ts/src/models/__tests__/vercel.test.tsstrands-ts/src/conversation-manager/__tests__/*.test.ts(4 files){ getConfig }object literals for a realMockMessageModelbecause TS's getter needs a real instance. Python's equivalents already useMagicMock/MockedModelProvider, whosemodel.context_window_limitis mocked directly; they pass unchanged.strands-ts/test/packages/assert-package-contents.jshatch buildwheel-contents gate inPORT_GATES.md(unzip -l dist/*.whl | grep model_info), plustest_package_copy_matches_the_shared_data.Review ledger: API bar-raiser, validator, reviewer (independent passes)
API bar-raiser (no 🔴):
kw_only=Trueon the records (positional construction locked field order) — done; sections always-present instead ofOptional(removes six guards,info.limits.context_windowtype-checks without casts) — done, Python-idiom deviation from TS; a public merge because frozen dataclasses make the manual merge painful (TS keepsmergeModelInfo@internal; spread does it inline) — done, then moved onto the type asModelInfo.mergeper the owner; missing copy → actionableFileNotFoundError— done; docstring/TypeAliasnits — 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 aValueErrornaming the source. 🟡estimateUtilizationoverride 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-streamstatefultest was tautological (nomodel_state, loose bound) — now passesmodel_stateand asserts exactly 1get_config()call; fails with 2 without the hoist. 🟢resolve_config_metadatachecks 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-awsreview (7 issues,684a2b8): 5 fixed — 🟡PromptCachingcarried TS casing ("cachePoints"→"cache_points", explicit load-time map, rootAGENTS.md:56); 🟡 deprecated Anthropiccache_toolsskipped the"auto"-unsupported gate (decision 4 superseded); 🟡 non-strmodel_idraisedTypeErrorfrom 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-strmodel_idguard sat inModel.model_infoonly, while every provider'sget_config()reaches the lookup first throughresolve_config_metadata(AnthropicModel(model_id=1234).model_infostill raised) — fixed, the guard is now inlookup_model_infoitself and aBedrockModel(model_id=None)test covers the provider path. 🟡_PROMPT_CACHING_VALUES[...]raisedKeyError→ import-timeValueErroron apromptCachingvalue this version does not know, contradicting the skip-unknown-facts rule two lines above — fixed with.get()(unknown →None), table typeddict[str, PromptCaching], test added. 🟢 TS tab ofcontext-estimation.mdxstill linkeddefaults.ts— fixed, both tabs point atshared/model-info.json. 🟢test_model.pycomment said the shared file "sayscache_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)
AnthropicModeldefaultmax_tokensis not derived fromlimits.max_output_tokens(known gap, named in the plan). TS mademaxTokensoptional and resolved it through_resolvedMaxTokens()(user →modelInfooverride → shared data → fixed 64 000). Python'sAnthropicConfig.max_tokensistyping_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_tokensis not ported. The data is still reachable asmodel.model_info.limits.max_output_tokens, which two new tests assert. Follow-up for the maintainers if they want parity: makemax_tokensoptional in Python and port_resolvedMaxTokensas a separate change.Bedrock's "no automatic caching" warning stays
logger.warninginstead of TS'swarnOnce.strands-py/AGENTS.md:82is explicit: "If you catch yourself building a process-global 'warn once' set over the logger, you almost certainly wantwarnings.warninstead." There is nowarn_oncehelper instrands-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_idsubstring match). Net effect vs TS: Python can log this line more than once per process.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, samestacklevel=1+ "pins dedup to this line" comment). That gives the once-per-process dedup TS wanted fromwarnOncewhile staying inside the house rule in decision 2. The two providers therefore use different mechanisms for similar notices — a pre-existing inconsistency instrands-py, preserved rather than papered over.The Python-only deprecated
cache_toolspath is not gated by the model info. TS has nocacheToolsoption (rg cacheTools strands-ts/src/models/anthropic.ts→ nothing);_cacheSectioncovers onlytoolsTTL/messagesTTL/systemPromptTTL, whose three Python equivalents are all gated. Gating_resolve_deprecated_cache_toolstoo would change behavior for a config shape that has no TS counterpart, so it is left alone. Superseded by684a2b8(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 keepcache_controlwhen messages and the system prompt drop it.No
model_idproperty override on any provider. TS addedoverride get modelId()toOpenAIModelandGoogleModelbecause their_config.modelIdcan 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 inself.config(Bedrock resolves its regional default in__init__;geminimarksmodel_idasRequired;openai/openai_responsesreadself.config["model_id"]directly), soModel.model_inforeadingget_config()["model_id"]already sees it. Nothing to add.The prefix-strip debug log is dropped. The old
_defaults.get_context_window_limitloggedlogger.debug("model_id=<%s>, stripped_id=<%s> | resolved context window limit via prefix strip", ...). TS'slookupModelhas no such log, and the lookup now runs on everymodel_inforead rather than once perget_config, so keeping it would be noisy. No test asserted it (checked).Model.model_infokeeps the dict/attribute dual read thatcontext_window_limithad.get_config()is typedAnyand real implementations returnNone(tests/fixtures/mocked_model_provider.py:53,TestModelintests/strands/models/test_model.py), so the property reads viaisinstance(config, dict)→.get(...), elsegetattr(config, ..., None). This is the behavior the replacedcontext_window_limitproperty already had; dropping it would break those providers.merge_model_inforeturnsModelInfo(capabilities=None, limits=None)where TS returns{}. Python's frozen dataclass with all-Nonefields is the structural equivalent of TS omitting an empty section, andModelInfo() == ModelInfo(capabilities=None, limits=None), so== ModelInfo()is the assertion form in the ported tests (mirroring TS'stoEqual({})). Superseded by test: make litellm integ test more robust #14 below: sections are always present.structured_output_mechanismstakesmodel_infoas a 4th positional parameter with aNonedefault, where TS defaults it to{}. Python's signature already differs from TS's (it takes a pre-computedunsupportedset rather thanprovider/model_id), so the parameter is appended in the same position TS used, keeping every existing call site valid.ConfigurableModelintests/strands/models/test_model.pywas hoisted from insideTestEstimateUtilizationto module level so the newTestModelInfoclass 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 andself.ConfigurableModel(...)becameConfigurableModel(...).artifactsin[tool.hatch.build], not the wheel target. Hatchling reads onlystrands-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 intostrands-py/.gitignore; verified with the rule added locally. Found byagent-of-mkmeralon this PR.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 andonly its test enforces the shape. My first cut mapped keys through a dict lookup, which raised
KeyErroratimport time instead — and because
shared/model-info.jsonis edited for both SDKs, a capability added forTypeScript first would have broken
import strandsfor 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 TSkeeps it. Found by injecting
capabilities.reasoninginto the copy and importing.Model.context_window_limitnow 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 callresolve_config_metadata(Ollama, LlamaCpp, LiteLLM, SageMaker, Writer, LlamaAPI) returnedNoneeven for amodel id the table knew. Reading through
model_infomeans those providers now get the shared value. This isthe TS behavior (
modelInforesolveslookupModelInfo(this.modelId)for every provider), and it is the pointof 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 untouchedfor them, so nothing that reads the config dict changes.
ModelInfo.capabilitiesandModelInfo.limitsare always-present empty records, notOptional.TS declares them
readonly capabilities?: ModelCapabilities(strands-ts/src/models/model-info.ts:46-47) andomits 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 Noneguards on every single read — seven of them, in
_model_info.py,model.py(two),anthropic.py,bedrock.py,_defaults.pyand_native_output.py. The records now default tocapabilities=ModelCapabilities()/limits=ModelLimits()(legal dataclass defaults because the sections are frozen), so "nothing known" is "every leaf is
None" anda reader is just
self.model_info.limits.context_window. Merge semantics are unchanged — a leaf that isNoneor 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 asection later cannot silently change the meaning of a positional argument. JSON entries are unaffected: the
loader's
_sectionreturns the empty section instead ofNone, and the strict-shape test overshared/model-info.jsonstill rejects unknown keys.merge_model_infois 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 outsidethe SDK to override
model_info(provider facts oversuper().model_info, user override over both). In TS asubclass can hand-roll that with object spread inline; in Python the records are frozen dataclasses, so the
equivalent is
dataclasses.replaceover two nested records with per-leafNonechecks — the exact logicmerge_model_infoalready implements. Keeping it private would mean every third-party provider importsstrands.models._model_info(a private module) or reimplements the merge. Owner decision: this widens thePython 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
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 intosrc/on every build and editable install. No new runtime dependencies (hatchlingwas already the backend).importlib.resourcesread +json.loadsonimport strands.warnings.warnadded (Anthropic"auto"+"unsupported"); the old prefix-striplogger.debugdropped. Bedrock's existinglogger.warningkept (AGENTS.md bans a hand-rolled warn-once set).strands.models.__all__(merge_model_infobecameModelInfo.merge).Model.statefulcontract.Open questions for the owner
— resolved withmerge_model_infopublicmkmeral: it isModelInfo.merge(*parts)(4acf306), a classmethod on the type rather than a loose function. TS can mirror it later by exporting aModelInfovalue merged with the type (export const ModelInfo = { merge: mergeModelInfo }), which also fixes the spread-vs-mergeundefineddivergence noted on feat(models): add Model.info with capabilities and limits (TypeScript) #5313.max_tokens— TS derives it fromlimits.max_output_tokens; Python'sAnthropicConfig.max_tokensisRequired[int], so this reader is not ported. Making it optional is an API change for a follow-up, if wanted.Unlisted Claude ids lose auto caching on Bedrock— folded into 5.opieter-awsissue 1; the old_defaults.pyexcluded them on purpose). Keep (TS's basemodelInfogetter does this for every provider), or add a 3-linemodel_infooverride in those four that skipslookup_model_info?"auto"caching for ids the lookup cannot resolve (opieter-awsissue 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
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.