simbiot-harness/venv/lib/python3.12/site-packages/strands/models/llamacpp.py
Simbiot Harness b60439152b Simbiot Harness v0.1: тактовая лестница на Strands + CQRS/ES-спина
- harness/es.py: append-only event store + проекции (образец — кооп-портал)
- harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу
- harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы
- harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе
  (глобальный asyncio-рантайм Strands не переживает 2+ create_harness)
- cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом)
- 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
2026-09-29 15:28:50 +00:00

873 lines
34 KiB
Python

"""llama.cpp model provider.
Provides integration with llama.cpp servers running in OpenAI-compatible mode,
with support for advanced llama.cpp-specific features.
- Docs: https://github.com/ggml-org/llama.cpp
- Server docs: https://github.com/ggml-org/llama.cpp/tree/master/tools/server
- OpenAI API compatibility:
https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md#api-endpoints
"""
import base64
import json
import logging
import mimetypes
import time
from collections.abc import AsyncGenerator
from typing import (
Any,
TypeVar,
cast,
)
import httpx
from pydantic import BaseModel
from typing_extensions import Unpack, override
from ..types.content import ContentBlock, Messages, SystemContentBlock
from ..types.event_loop import Usage
from ..types.exceptions import ContextWindowOverflowException, ModelThrottledException
from ..types.streaming import StreamEvent
from ..types.tools import ToolChoice, ToolSpec
from ._validation import (
_has_location_source,
validate_config_keys,
warn_on_cache_config_not_supported,
warn_on_tool_choice_not_supported,
)
from .model import BaseModelConfig, CacheConfig, Model
logger = logging.getLogger(__name__)
T = TypeVar("T", bound=BaseModel)
def _apply_cache_config(request: dict[str, Any], cache_config: CacheConfig | None) -> None:
"""Pin llama.cpp's server-side prompt caching on from a CacheConfig.
llama.cpp uses the shared prompt prefix server-side by default; setting ``cache_prompt`` keeps
caching on. It has no per-field cache controls, so every other CacheConfig field is ignored.
Args:
request: The llama.cpp request dict to mutate.
cache_config: The provider's configured cache settings, if any.
"""
if cache_config is None:
return
if "cache_prompt" not in request:
request["cache_prompt"] = True
warn_on_cache_config_not_supported(cache_config, "llama.cpp", supported=set())
def _format_usage(data: Any) -> Usage:
"""Build a Usage from a llama.cpp usage payload.
Args:
data: The usage payload from the server's final stream chunk.
Returns:
The standardized token usage.
"""
usage: Usage = {
"inputTokens": data.prompt_tokens,
"outputTokens": data.completion_tokens,
"totalTokens": data.total_tokens,
}
if cached_tokens := getattr(data, "cached_tokens", 0):
usage["cacheReadInputTokens"] = cached_tokens
return usage
class LlamaCppModel(Model):
"""llama.cpp model provider implementation.
Connects to a llama.cpp server running in OpenAI-compatible mode with
support for advanced llama.cpp-specific features like grammar constraints,
Mirostat sampling, native JSON schema validation, and native multimodal
support for audio and image content.
The llama.cpp server must be started with the OpenAI-compatible API enabled:
llama-server -m model.gguf --host 0.0.0.0 --port 8080
Example:
Basic usage:
>>> model = LlamaCppModel(base_url="http://localhost:8080")
>>> model.update_config(params={"temperature": 0.7, "top_k": 40})
Grammar constraints via params:
>>> model.update_config(params={
... "grammar": '''
... root ::= answer
... answer ::= "yes" | "no"
... '''
... })
Advanced sampling:
>>> model.update_config(params={
... "mirostat": 2,
... "mirostat_lr": 0.1,
... "tfs_z": 0.95,
... "repeat_penalty": 1.1
... })
Multimodal usage (requires multimodal model like Qwen2.5-Omni):
>>> # Audio analysis
>>> audio_content = [{
... "audio": {"source": {"bytes": audio_bytes}, "format": "wav"},
... "text": "What do you hear in this audio?"
... }]
>>> response = agent(audio_content)
>>> # Image analysis
>>> image_content = [{
... "image": {"source": {"bytes": image_bytes}, "format": "png"},
... "text": "Describe this image"
... }]
>>> response = agent(image_content)
"""
class LlamaCppConfig(BaseModelConfig, total=False):
"""Configuration options for llama.cpp models.
Attributes:
model_id: Model identifier for the loaded model in llama.cpp server.
Default is "default" as llama.cpp typically loads a single model.
params: Model parameters supporting both OpenAI and llama.cpp-specific options.
OpenAI-compatible parameters:
- max_tokens: Maximum number of tokens to generate
- temperature: Sampling temperature (0.0 to 2.0)
- top_p: Nucleus sampling parameter (0.0 to 1.0)
- frequency_penalty: Frequency penalty (-2.0 to 2.0)
- presence_penalty: Presence penalty (-2.0 to 2.0)
- stop: List of stop sequences
- seed: Random seed for reproducibility
- n: Number of completions to generate
- logprobs: Include log probabilities in output
- top_logprobs: Number of top log probabilities to include
llama.cpp-specific parameters:
- repeat_penalty: Penalize repeat tokens (1.0 = no penalty)
- top_k: Top-k sampling (0 = disabled)
- min_p: Min-p sampling threshold (0.0 to 1.0)
- typical_p: Typical-p sampling (0.0 to 1.0)
- tfs_z: Tail-free sampling parameter (0.0 to 1.0)
- top_a: Top-a sampling parameter
- mirostat: Mirostat sampling mode (0, 1, or 2)
- mirostat_lr: Mirostat learning rate
- mirostat_ent: Mirostat target entropy
- grammar: GBNF grammar string for constrained generation
- json_schema: JSON schema for structured output
- penalty_last_n: Number of tokens to consider for penalties
- n_probs: Number of probabilities to return per token
- min_keep: Minimum tokens to keep in sampling
- ignore_eos: Ignore end-of-sequence token
- logit_bias: Token ID to bias mapping
- cache_prompt: Cache the prompt for faster generation
- slot_id: Slot ID for parallel inference
- samplers: Custom sampler order
use_native_token_count: Whether to use the native llama.cpp /tokenize endpoint.
When True, count_tokens() calls the server's tokenize endpoint for accurate counts.
When False (default), skips the API call and uses the local estimator.
cache_config: Prompt-caching configuration. llama.cpp reuses the shared prompt prefix
server-side by default. A CacheConfig pins ``cache_prompt: true`` on each request. An
explicit ``params["cache_prompt"]`` takes precedence. llama.cpp has no per-field cache
controls, so every other CacheConfig field is ignored.
"""
model_id: str
params: dict[str, Any] | None
use_native_token_count: bool
cache_config: CacheConfig | None
def __init__(
self,
base_url: str = "http://localhost:8080",
timeout: float | tuple[float, float] | None = None,
**model_config: Unpack[LlamaCppConfig],
) -> None:
"""Initialize llama.cpp provider instance.
Args:
base_url: Base URL for the llama.cpp server.
Default is "http://localhost:8080" for local server.
timeout: Request timeout in seconds. Can be float or tuple of
(connect, read) timeouts.
**model_config: Configuration options for the llama.cpp model.
"""
validate_config_keys(model_config, self.LlamaCppConfig)
# Set default model_id if not provided
if "model_id" not in model_config:
model_config["model_id"] = "default"
self.base_url = base_url.rstrip("/")
self.config = dict(model_config)
logger.debug("config=<%s> | initializing", self.config)
# Configure HTTP client
if isinstance(timeout, tuple):
# Convert tuple to httpx.Timeout object
timeout_obj = httpx.Timeout(
connect=timeout[0] if len(timeout) > 0 else None,
read=timeout[1] if len(timeout) > 1 else None,
write=timeout[2] if len(timeout) > 2 else None,
pool=timeout[3] if len(timeout) > 3 else None,
)
else:
timeout_obj = httpx.Timeout(timeout or 30.0)
self.client = httpx.AsyncClient(
base_url=self.base_url,
timeout=timeout_obj,
)
@override
def update_config(self, **model_config: Unpack[LlamaCppConfig]) -> None: # type: ignore[override]
"""Update the llama.cpp model configuration with provided arguments.
Args:
**model_config: Configuration overrides.
"""
validate_config_keys(model_config, self.LlamaCppConfig)
self.config.update(model_config)
@override
def get_config(self) -> LlamaCppConfig:
"""Get the llama.cpp model configuration.
Returns:
The llama.cpp model configuration.
"""
return self.config # type: ignore[return-value]
def _format_message_content(self, content: ContentBlock | dict[str, Any]) -> dict[str, Any]:
"""Format a content block for llama.cpp.
Args:
content: Message content.
Returns:
llama.cpp compatible content block.
Raises:
TypeError: If the content block type cannot be converted to a compatible format.
"""
if "document" in content:
mime_type = mimetypes.types_map.get(f".{content['document']['format']}", "application/octet-stream")
file_data = base64.b64encode(content["document"]["source"]["bytes"]).decode("utf-8")
return {
"file": {
"file_data": f"data:{mime_type};base64,{file_data}",
"filename": content["document"]["name"],
},
"type": "file",
}
if "image" in content:
mime_type = mimetypes.types_map.get(f".{content['image']['format']}", "application/octet-stream")
image_data = base64.b64encode(content["image"]["source"]["bytes"]).decode("utf-8")
return {
"image_url": {
"detail": "auto",
"format": mime_type,
"url": f"data:{mime_type};base64,{image_data}",
},
"type": "image_url",
}
# Handle audio content.
if "audio" in content:
audio_content = content["audio"]
audio_data = base64.b64encode(audio_content["source"]["bytes"]).decode("utf-8")
audio_format = audio_content.get("format", "wav")
return {
"type": "input_audio",
"input_audio": {"data": audio_data, "format": audio_format},
}
if "text" in content:
return {"text": content["text"], "type": "text"}
raise TypeError(f"content_type=<{next(iter(content))}> | unsupported type")
def _format_tool_call(self, tool_use: dict[str, Any]) -> dict[str, Any]:
"""Format a tool call for llama.cpp.
Args:
tool_use: Tool use requested by the model.
Returns:
llama.cpp compatible tool call.
"""
return {
"function": {
"arguments": json.dumps(tool_use["input"], ensure_ascii=False),
"name": tool_use["name"],
},
"id": tool_use["toolUseId"],
"type": "function",
}
def _format_tool_message(self, tool_result: dict[str, Any]) -> dict[str, Any]:
"""Format a tool message for llama.cpp.
Args:
tool_result: Tool result collected from a tool execution.
Returns:
llama.cpp compatible tool message.
"""
contents = [
{"text": json.dumps(content["json"], ensure_ascii=False)} if "json" in content else content
for content in tool_result["content"]
]
return {
"role": "tool",
"tool_call_id": tool_result["toolUseId"],
"content": [self._format_message_content(content) for content in contents],
}
def _format_messages(self, messages: Messages, system_prompt: str | None = None) -> list[dict[str, Any]]:
"""Format messages for llama.cpp.
Args:
messages: List of message objects to be processed.
system_prompt: System prompt to provide context to the model.
Returns:
Formatted messages array compatible with llama.cpp.
"""
formatted_messages: list[dict[str, Any]] = []
# Add system prompt if provided
if system_prompt:
formatted_messages.append({"role": "system", "content": system_prompt})
for message in messages:
contents = message["content"]
# Filter out location sources and unsupported block types
filtered_contents = []
for content in contents:
if any(block_type in content for block_type in ["toolResult", "toolUse"]):
continue
if _has_location_source(content):
logger.warning("Location sources are not supported by llama.cpp | skipping content block")
continue
filtered_contents.append(content)
formatted_contents = [self._format_message_content(content) for content in filtered_contents]
formatted_tool_calls = [
self._format_tool_call(
{
"name": content["toolUse"]["name"],
"input": content["toolUse"]["input"],
"toolUseId": content["toolUse"]["toolUseId"],
}
)
for content in contents
if "toolUse" in content
]
formatted_tool_messages = [
self._format_tool_message(
{
"toolUseId": content["toolResult"]["toolUseId"],
"content": content["toolResult"]["content"],
}
)
for content in contents
if "toolResult" in content
]
formatted_message = {
"role": message["role"],
"content": formatted_contents,
**({} if not formatted_tool_calls else {"tool_calls": formatted_tool_calls}),
}
formatted_messages.append(formatted_message)
formatted_messages.extend(formatted_tool_messages)
return [message for message in formatted_messages if message["content"] or "tool_calls" in message]
def _format_request(
self,
messages: Messages,
tool_specs: list[ToolSpec] | None = None,
system_prompt: str | None = None,
) -> dict[str, Any]:
"""Format a request for the llama.cpp server.
Args:
messages: List of message objects to be processed by the model.
tool_specs: List of tool specifications to make available to the model.
system_prompt: System prompt to provide context to the model.
Returns:
A request formatted for llama.cpp server's OpenAI-compatible API.
"""
# Separate OpenAI-compatible and llama.cpp-specific parameters
request = {
"messages": self._format_messages(messages, system_prompt),
"model": self.config["model_id"],
"stream": True,
"stream_options": {"include_usage": True},
"tools": [
{
"type": "function",
"function": {
"name": tool_spec["name"],
"description": tool_spec["description"],
"parameters": tool_spec["inputSchema"]["json"],
},
}
for tool_spec in tool_specs or []
],
}
# Handle parameters if provided
params = self.config.get("params")
if params and isinstance(params, dict):
# Grammar and json_schema go directly in request body for llama.cpp server
if "grammar" in params:
request["grammar"] = params["grammar"]
if "json_schema" in params:
request["json_schema"] = params["json_schema"]
# llama.cpp-specific sampling parameters. The llama.cpp server reads these
# from the top level of the request body, which is also where grammar and
# json_schema (handled above) are placed.
llamacpp_specific_params = {
"repeat_penalty",
"top_k",
"min_p",
"typical_p",
"tfs_z",
"top_a",
"mirostat",
"mirostat_lr",
"mirostat_ent",
"penalty_last_n",
"n_probs",
"min_keep",
"ignore_eos",
"logit_bias",
"cache_prompt",
"slot_id",
"samplers",
}
# Standard OpenAI parameters that go directly in the request
openai_params = {
"temperature",
"max_tokens",
"top_p",
"frequency_penalty",
"presence_penalty",
"stop",
"seed",
"n",
"logprobs",
"top_logprobs",
"response_format",
}
# Add OpenAI parameters directly to request
for param, value in params.items():
if param in openai_params:
request[param] = value
# Add llama.cpp-specific parameters directly to the request body
for param, value in params.items():
if param in llamacpp_specific_params:
request[param] = value
_apply_cache_config(request, cast(CacheConfig | None, self.config.get("cache_config")))
return request
def _format_chunk(self, event: dict[str, Any]) -> StreamEvent:
"""Format a llama.cpp response event into a standardized message chunk.
Args:
event: A response event from the llama.cpp server.
Returns:
The formatted chunk.
Raises:
RuntimeError: If chunk_type is not recognized.
"""
match event["chunk_type"]:
case "message_start":
return {"messageStart": {"role": "assistant"}}
case "content_start":
if event["data_type"] == "tool":
return {
"contentBlockStart": {
"start": {
"toolUse": {
"name": event["data"].function.name,
"toolUseId": event["data"].id,
}
}
}
}
return {"contentBlockStart": {"start": {}}}
case "content_delta":
if event["data_type"] == "tool":
return {
"contentBlockDelta": {"delta": {"toolUse": {"input": event["data"].function.arguments or ""}}}
}
if event["data_type"] == "reasoning_content":
return {"contentBlockDelta": {"delta": {"reasoningContent": {"text": event["data"]}}}}
return {"contentBlockDelta": {"delta": {"text": event["data"]}}}
case "content_stop":
return {"contentBlockStop": {}}
case "message_stop":
match event["data"]:
case "tool_calls":
return {"messageStop": {"stopReason": "tool_use"}}
case "length":
return {"messageStop": {"stopReason": "max_tokens"}}
case _:
return {"messageStop": {"stopReason": "end_turn"}}
case "metadata":
return {
"metadata": {
"usage": _format_usage(event["data"]),
"metrics": {
"latencyMs": event.get("latency_ms", 0),
},
},
}
case _:
raise RuntimeError(f"chunk_type=<{event['chunk_type']}> | unknown type")
@override
async def count_tokens(
self,
messages: Messages,
tool_specs: list[ToolSpec] | None = None,
system_prompt: str | None = None,
system_prompt_content: list[SystemContentBlock] | None = None,
) -> int:
"""Count tokens using llama.cpp's native /tokenize endpoint.
Sends the formatted prompt to the llama.cpp server's tokenization endpoint
to get an accurate token count. Requires a llama.cpp server version that supports
chat-template-aware tokenization via the ``messages`` field in /tokenize requests.
Older server versions that only accept ``{"content": "string"}`` are not supported
and will fall back to estimation.
Args:
messages: List of message objects to count tokens for.
tool_specs: List of tool specifications to include in the count.
system_prompt: Plain string system prompt. Ignored if system_prompt_content is provided.
system_prompt_content: Structured system prompt content blocks.
Returns:
Total input token count.
"""
if self.config.get("use_native_token_count") is not True:
return await super().count_tokens(messages, tool_specs, system_prompt, system_prompt_content)
try:
# system_prompt_content is not used; this provider only accepts system_prompt as a plain string,
# matching the behavior of stream(). The caller always provides system_prompt alongside
# system_prompt_content, so the plain string is always available.
request = self._format_request(messages, tool_specs, system_prompt)
payload = {
"messages": request["messages"],
**({"tools": request["tools"]} if request.get("tools") else {}),
}
response = await self.client.post("/tokenize", json=payload)
response.raise_for_status()
data = response.json()
total_tokens: int = len(data.get("tokens", []))
logger.debug(
"model_id=<%s>, total_tokens=<%d> | native token count",
self.config.get("model_id", "default"),
total_tokens,
)
return total_tokens
except Exception as e:
logger.debug(
"model_id=<%s>, error=<%s> | native token counting failed, falling back to estimation",
self.config.get("model_id", "default"),
e,
)
return await super().count_tokens(messages, tool_specs, system_prompt, system_prompt_content)
@override
async def stream(
self,
messages: Messages,
tool_specs: list[ToolSpec] | None = None,
system_prompt: str | None = None,
*,
tool_choice: ToolChoice | None = None,
**kwargs: Any,
) -> AsyncGenerator[StreamEvent, None]:
"""Stream conversation with the llama.cpp model.
Args:
messages: List of message objects to be processed by the model.
tool_specs: List of tool specifications to make available to the model.
system_prompt: System prompt to provide context to the model.
tool_choice: Selection strategy for tool invocation. **Note: This parameter is accepted for
interface consistency but is currently ignored for this model provider.**
**kwargs: Additional keyword arguments for future extensibility.
Yields:
Formatted message chunks from the model.
Raises:
ContextWindowOverflowException: When the context window is exceeded.
ModelThrottledException: When the llama.cpp server is overloaded.
"""
warn_on_tool_choice_not_supported(tool_choice)
# Track request start time for latency calculation
start_time = time.perf_counter()
try:
logger.debug("formatting request")
request = self._format_request(messages, tool_specs, system_prompt)
logger.debug("request=<%s>", request)
logger.debug("invoking model")
response = await self.client.post("/v1/chat/completions", json=request)
response.raise_for_status()
logger.debug("got response from model")
yield self._format_chunk({"chunk_type": "message_start"})
yield self._format_chunk({"chunk_type": "content_start", "data_type": "text"})
tool_calls: dict[int, list] = {}
usage_data = None
finish_reason = None
async for line in response.aiter_lines():
if not line.strip() or not line.startswith("data: "):
continue
data_content = line[6:] # Remove "data: " prefix
if data_content.strip() == "[DONE]":
break
try:
event = json.loads(data_content)
except json.JSONDecodeError:
continue
# Handle usage information
if "usage" in event:
usage_data = event["usage"]
continue
if not event.get("choices"):
continue
choice = event["choices"][0]
delta = choice.get("delta", {})
# Handle content deltas
if "content" in delta and delta["content"]:
yield self._format_chunk(
{
"chunk_type": "content_delta",
"data_type": "text",
"data": delta["content"],
}
)
# Handle tool calls
if "tool_calls" in delta:
for tool_call in delta["tool_calls"]:
index = tool_call["index"]
if index not in tool_calls:
tool_calls[index] = []
tool_calls[index].append(tool_call)
# Capture the finish reason but do NOT break here: current
# llama.cpp server ordering is
# finish -> usage -> [DONE]
# so a `break` would drop the trailing usage payload and the
# resulting `metadata` chunk would never reach the caller.
# Mirrors the OpenAI provider's drain-after-finish behavior.
if choice.get("finish_reason"):
finish_reason = choice.get("finish_reason")
yield self._format_chunk({"chunk_type": "content_stop"})
# Process tool calls
for tool_deltas in tool_calls.values():
first_delta = tool_deltas[0]
yield self._format_chunk(
{
"chunk_type": "content_start",
"data_type": "tool",
"data": type(
"ToolCall",
(),
{
"function": type(
"Function",
(),
{
"name": first_delta.get("function", {}).get("name", ""),
},
)(),
"id": first_delta.get("id", ""),
},
)(),
}
)
for tool_delta in tool_deltas:
yield self._format_chunk(
{
"chunk_type": "content_delta",
"data_type": "tool",
"data": type(
"ToolCall",
(),
{
"function": type(
"Function",
(),
{
"arguments": tool_delta.get("function", {}).get("arguments", ""),
},
)(),
},
)(),
}
)
yield self._format_chunk({"chunk_type": "content_stop"})
# Send stop reason
if finish_reason == "tool_calls" or tool_calls:
stop_reason = "tool_calls" # Changed from "tool_use" to match format_chunk expectations
else:
stop_reason = finish_reason or "end_turn"
yield self._format_chunk({"chunk_type": "message_stop", "data": stop_reason})
# Send usage metadata if available
if usage_data:
# Calculate latency
latency_ms = int((time.perf_counter() - start_time) * 1000)
# cached_tokens is the length of the prompt prefix llama.cpp reused from its KV cache.
cached_tokens = (usage_data.get("prompt_tokens_details") or {}).get("cached_tokens", 0)
yield self._format_chunk(
{
"chunk_type": "metadata",
"data": type(
"Usage",
(),
{
"prompt_tokens": usage_data.get("prompt_tokens", 0),
"completion_tokens": usage_data.get("completion_tokens", 0),
"total_tokens": usage_data.get("total_tokens", 0),
"cached_tokens": cached_tokens,
},
)(),
"latency_ms": latency_ms,
}
)
logger.debug("finished streaming response from model")
except httpx.HTTPStatusError as e:
if e.response.status_code == 400:
# Parse error response from llama.cpp server
try:
error_data = e.response.json()
error_msg = str(error_data.get("error", {}).get("message", str(error_data)))
except (json.JSONDecodeError, KeyError, AttributeError):
error_msg = e.response.text
# Check for context overflow by looking for specific error indicators
if any(term in error_msg.lower() for term in ["context", "kv cache", "slot"]):
raise ContextWindowOverflowException(f"Context window exceeded: {error_msg}") from e
elif e.response.status_code == 503:
raise ModelThrottledException("llama.cpp server is busy or overloaded") from e
raise
except Exception as e:
# Handle other potential errors like rate limiting
error_msg = str(e).lower()
if "rate" in error_msg or "429" in str(e):
raise ModelThrottledException(str(e)) from e
raise
@override
async def structured_output(
self,
output_model: type[T],
prompt: Messages,
system_prompt: str | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, T | Any], None]:
"""Get structured output using llama.cpp's native JSON schema support.
This implementation uses llama.cpp's json_schema parameter to constrain
the model output to valid JSON matching the provided schema.
Args:
output_model: The Pydantic model defining the expected output structure.
prompt: The prompt messages to use for generation.
system_prompt: System prompt to provide context to the model.
**kwargs: Additional keyword arguments for future extensibility.
Yields:
Model events with the last being the structured output.
Raises:
json.JSONDecodeError: If the model output is not valid JSON.
pydantic.ValidationError: If the output doesn't match the model schema.
"""
# Get the JSON schema from the Pydantic model
schema = output_model.model_json_schema()
# Store current params to restore later
params = self.config.get("params", {})
original_params = dict(params) if isinstance(params, dict) else {}
try:
# Configure for JSON output with schema constraint
params = self.config.get("params", {})
if not isinstance(params, dict):
params = {}
params["json_schema"] = schema
params["cache_prompt"] = True
self.config["params"] = params
# Collect the response
response_text = ""
async for event in self.stream(prompt, system_prompt=system_prompt, **kwargs):
if "contentBlockDelta" in event:
delta = event["contentBlockDelta"]["delta"]
if "text" in delta:
response_text += delta["text"]
# Forward events to caller
yield cast(dict[str, T | Any], event)
# Parse and validate the JSON response
data = json.loads(response_text.strip())
output_instance = output_model(**data)
yield {"output": output_instance}
finally:
# Restore original configuration
self.config["params"] = original_params