feat: add host model provider interface in wit contract, host model provider to TS and SageMaker support to PY to enable host model calling for wasm architecture#877
Conversation
…rovider to TS and SageMaker support to PY to enable host model calling for wasm architecture
|
|
||
| /// Invoke the host-side model and return the full sequence of events as a JSON array. | ||
| /// | ||
| /// Stopgap: the ideal signature is result<list<model-event>, string>, |
There was a problem hiding this comment.
The wastime-py fork needs a fix we can make it there. In the meantime this is a temporary work around that allows the review process to continue further. Once the fix is implemented and merged in the fork will update the implementation in the wit contract.
| # Detect host-side model providers (have stream() method — e.g. SageMaker, Mistral, Writer). | ||
| # These run on the Python side and are invoked by the WASM guest via model-provider import. | ||
| model_provider_callback = None | ||
| if model is not None and hasattr(model, "stream") and callable(getattr(model, "stream", None)): |
There was a problem hiding this comment.
Issue: Host-model detection via duck-typing is fragile and may produce false positives. hasattr(model, "stream") would match any model that implements stream() — including custom in-guest model providers that subclass Model and define their own stream(). This means a user who passes a custom Model subclass with a stream() method would unexpectedly get routed to the host-model path.
Suggestion: Use a more explicit mechanism to distinguish host-side models. Consider:
- A marker base class or protocol (e.g.,
HostSideModel) that host-side providers must inherit from, or - A negative check: verify the model does NOT have
_to_config_dict()(the in-guest model marker) before falling through to host-model detection, or - Both:
if model is not None and not hasattr(model, '_to_config_dict') and hasattr(model, 'stream')
This would make the dispatch explicit rather than relying on duck-typing that could be accidentally triggered.
| from dataclasses import dataclass | ||
| from typing import Any, TypedDict | ||
|
|
||
| import boto3 |
There was a problem hiding this comment.
Issue: boto3 is imported at module top-level, but it's only listed as a test dependency ([project.optional-dependencies].test) in pyproject.toml, not a core dependency. Since SageMakerAIModel is re-exported from strands.models.__init__, any from strands.models import ... or import strands.models will trigger this import and fail with ModuleNotFoundError for users who don't have boto3 installed.
Suggestion: Either:
- Make
boto3an optional dependency and use a lazy import:boto3 = None; try: import boto3; except ImportError: pass(with a clear error at__init__time), or - Remove
SageMakerAIModelfrom the top-levelstrands.models.__init__re-export so it must be imported explicitly (from strands.models.sagemaker import SageMakerAIModel), or - Add
boto3andbotocoreas core dependencies (less ideal as it bloats the package for non-SageMaker users)
The existing in-guest model providers (Bedrock, Anthropic, OpenAI, Gemini) don't have this issue because they don't import SDK clients — they delegate to the TS side.
| self.function = FunctionCall(**kwargs.get("function", {"name": "", "arguments": ""})) | ||
|
|
||
|
|
||
| class SageMakerAIModel: |
There was a problem hiding this comment.
Issue: SageMakerAIModel does not extend the Model base class, unlike all other model providers (BedrockModel(Model), AnthropicModel(Model), etc.). This breaks the established pattern and means SageMakerAIModel won't pass isinstance(model, Model) checks.
Suggestion: Have SageMakerAIModel extend Model. Since host-side models don't need _to_config_dict(), you could either leave it unimplemented (raise NotImplementedError) or implement it to return the host-model config. This also helps with the duck-typing detection issue in agent/__init__.py — you could check isinstance(model, Model) and not hasattr(model, '_to_config_dict') or introduce a HostModel(Model) base in Python.
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def _run_in_thread(coro: Any) -> Any: |
There was a problem hiding this comment.
Issue: _run_in_thread is functionally identical to the "running loop" branch of _run_sync in _wasm_host.py. This duplication violates the DRY principle and the repo guideline to "reduce code duplication, even if the refactoring takes extra effort."
Suggestion: Extract a shared utility (e.g., in _wasm_host.py or a new _async_util.py) and import it from both locations.
| args.toolSpecs = toolSpecs | ||
| } | ||
| events = this._invoke(args) | ||
| } catch (err: any) { |
There was a problem hiding this comment.
Issue: err is typed as any (line 93: catch (err: any)), which is explicitly disallowed by the repo's ESLint rules and AGENTS.md ("Never use any type").
Suggestion: Use catch (err: unknown) and narrow the type appropriately:
catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
logger.error('HostModel: host invoke failed', { error: message })
throw new Error(`Host model provider error: ${message}`)
}|
|
||
| export class HostModel extends Model<HostModelConfig> { | ||
| private _config: HostModelConfig | ||
| private _invoke: (args: { |
There was a problem hiding this comment.
Issue: The _invoke callback type signature is duplicated three times in this file (class property declaration, constructor parameter, and the args object in stream()). This makes the code harder to maintain — any change requires updating all three locations.
Suggestion: Extract a named type alias:
export type HostModelInvokeArgs = {
messages: string
systemPrompt?: string
toolSpecs?: Array<{ name: string; description: string; inputSchema: string }>
config: string
}
export type HostModelInvokeFn = (args: HostModelInvokeArgs) => Array<{ data: string }>Then use HostModelInvokeFn for the class property and constructor parameter.
| from collections.abc import Mapping | ||
| from typing import Any | ||
|
|
||
| from typing_extensions import get_type_hints |
There was a problem hiding this comment.
Issue: _validation.py imports typing_extensions.get_type_hints, but typing_extensions is not listed as a dependency in pyproject.toml. This will cause an ImportError at runtime.
Suggestion: Either add typing_extensions to the project dependencies, or use typing.get_type_hints from the standard library (available since Python 3.5).
| raw_specs = getattr(args, "tool-specs") | ||
| tool_specs_json: str | None = None | ||
| if raw_specs is not None: | ||
| import json as _json |
There was a problem hiding this comment.
Issue: json is imported inline twice with aliases (_json, _json2) on lines 514 and 530 instead of using the module-level import. This is unusual and unnecessary — json is a stdlib module with negligible import cost.
Suggestion: Use the module-level import json that already exists at the top of the file (or add it if missing). Remove the inline aliases.
| }): Array<{ data: string }> { | ||
| const result = hostModelInvoke(args); | ||
| let jsonStr: string; | ||
| if (typeof result === 'object' && result !== null && 'tag' in result) { |
There was a problem hiding this comment.
Issue: The unwrapHostModelResult function extensively uses as any casts (lines 180-186) to work around the WIT result type. This bypasses TypeScript's type safety and is prohibited by the repo's coding standards.
Suggestion: Define a proper type for the WIT result variant:
type WitResult = { tag: 'ok'; val: string } | { tag: 'err'; val: string }Then use type narrowing instead of casts:
function unwrapHostModelResult(args: ...): Array<{ data: string }> {
const result = hostModelInvoke(args) as WitResult
if (result.tag === 'err') {
throw new Error(result.val)
}
const eventStrings: string[] = JSON.parse(result.val)
return eventStrings.map((s) => ({ data: s }))
}|
|
||
| for tool_deltas in tool_calls.values(): | ||
| if not tool_deltas[0]["function"].get("name"): | ||
| raise Exception("The model did not provide a tool name.") |
There was a problem hiding this comment.
Issue: raise Exception(...) is used instead of a more specific exception type. Bare Exception makes it harder for callers to distinguish tool-name errors from other failures.
Suggestion: Use ValueError or a custom exception type, consistent with the error handling patterns in the rest of the file.
| /// Currently unused in the invoke return type due to a wasmtime-py | ||
| /// limitation — see the comment on model-provider.invoke below. | ||
| /// Retained for future use when the return type can be upgraded to | ||
| /// result<list<model-event>, string>. |
There was a problem hiding this comment.
Issue: The model-event record is defined in the WIT contract but explicitly documented as unused ("Currently unused in the invoke return type due to a wasmtime-py limitation"). WIT contracts define the public API surface between host and guest — unused types add dead weight and can confuse future contributors.
Suggestion: Remove model-event from the WIT contract for now and add it back when the wasmtime-py limitation is resolved. The comment block already documents the workaround rationale, which is sufficient. Alternatively, if you want to keep it as a documentation-of-intent, consider adding it as a WIT comment rather than a live type.
Review SummaryAssessment: Request Changes This PR introduces a well-architected pattern for delegating model inference from the WASM guest back to the Python host — an important capability for providers where mature Python implementations already exist. The WIT contract additions are clean and the overall data flow (Python → WIT → TS HostModel → WIT → Python adapter → provider) is sound. However, there are several issues that should be addressed before merging. Review Categories
The extensibility pattern here is strong — making it easy to add new host-side providers (Mistral, Writer, etc.) in the future. |
Description
The existing architecture requires every model provider to be implemented in TypeScript and compiled into the WASM guest. This works for providers with well-supported JS SDKs (Bedrock, Anthropic, OpenAI, Gemini), but creates a barrier for providers like SageMaker, Mistral, and Writer where mature Python implementations already exist. Rather than rewriting each provider in TypeScript, this change introduces a model-provider WIT import that lets the WASM guest delegate model inference back to the Python host — the same pattern used by tool-provider for tool execution.
SageMaker is the first model provider to use this mechanism.
Public API Changes
Python developers can now pass host-side model providers directly to the Agent:
The Agent automatically detects host-side models (any model with a stream() method) and routes them through the model-provider WIT import. In-guest providers continue to work unchanged.
The WIT contract adds a host-model variant to model-config and a new model-provider interface with an invoke function.
Related Issues
Documentation PR
Type of Change
Bug fix
New feature
Breaking change
Documentation update
Other (please describe):
Testing
How have you tested the change?
npm run checkChecklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.