This commit is contained in:
ishaan-j-berri 2026-11-07 07:24:10 +00:00 • committed by GitHub
commit 5b838cc2f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2369 additions and 4 deletions

View file

@ -32,7 +32,9 @@ IGNORE_ARG_RE: Final = re.compile(r"--ignore(?:-glob)?[= ](\S+)")
DOCKERFILE_TOKEN_RE = re.compile(r"[A-Za-z0-9_./-]*Dockerfile[A-Za-z0-9_.-]*")
COMMENT_RE = re.compile(r"^\s*#.*$", re.MULTILINE)
GLOB_CHARS = frozenset("*?")
DEDICATED_INTEGRATION_WORKFLOWS: Final = MappingProxyType({"image-scan.yml": "tests/integration/database/image/"})
DEDICATED_INTEGRATION_WORKFLOWS: Final = MappingProxyType(
{"image-scan.yml": "tests/integration/database/image/", "test-integration.yml": "tests/integration/sandbox/"}
)
# Trees whose jobs are sharded with no catch-all bucket, so every child that holds
# tests has to be named by some shard or it runs nowhere. A child listed here is

View file

@ -2,7 +2,7 @@ name: integration
on:
pull_request:
types: [labeled]
types: [opened, synchronize, reopened, labeled]
schedule:
- cron: "5 8-20/3 * * 1-6"
timezone: America/Los_Angeles
@ -25,6 +25,7 @@ on:
- cost
- sdk
- browser
- monty
default: all
workers:
description: Workers per group, blank keeps each group's default
@ -39,9 +40,31 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
monty:
name: Monty sandbox
if: github.event_name != 'workflow_dispatch' || inputs.suite == 'all' || inputs.suite == 'monty'
runs-on: ubuntu-latest
timeout-minutes: 10
env:
LITELLM_LOCAL_MODEL_COST_MAP: "True"
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- uses: ./.github/actions/setup-uv-with-retries
with:
version: "0.10.9"
- name: Install proxy test dependencies
run: .github/scripts/uv_sync_with_retries.sh --frozen --extra proxy --group dev --group proxy-dev --group e2e-dev
- name: Run real Monty and validator workers
run: uv run --no-sync pytest tests/integration/sandbox/test_monty_runtime.py -q
integration:
name: integration ${{ matrix.suite }}
if: github.event_name != 'pull_request' || github.event.label.name == 'run-ci'
if: inputs.suite != 'monty' && (github.event_name != 'pull_request' || github.event.label.name == 'run-ci')
environment: integration-license
runs-on: ${{ fromJSON('{"providers":"ubuntu-latest-64-cores","extensions":"ubuntu-latest-64-cores","accounting":"ubuntu-latest-32-cores","management":"ubuntu-latest-32-cores","management-replica":"ubuntu-latest-32-cores"}')[matrix.suite] || 'ubuntu-latest-16-cores' }}
timeout-minutes: 60

View file

@ -598,6 +598,7 @@ jobs:
tests/unit/proxy/ui_crud_endpoints
tests/unit/proxy/deps
tests/unit/proxy/config_resolvers
tests/unit/proxy/workflows
tests/unit/proxy/utils
python-version: "3.12"
workers: 4

View file

@ -0,0 +1,11 @@
# Monty security boundary
Never pass host callbacks, mounts, environment variables, credentials, or live SDK objects into Monty
Never import, exec, or eval uploaded source on the gateway. Inspect it with AST only
Never fall back to CPython execution. Missing or unverified workers fail closed
Monty's max_memory is an allocator budget, not an RSS limit. Apply the worker address-space limit separately
Language isolation is not an OS security boundary. An interpreter escape requires separate OS-level containment

View file

@ -0,0 +1,50 @@
from __future__ import annotations
import json
import sys
from collections.abc import Mapping
from typing import Final, NoReturn, TypeAlias, cast # noqa: TID251 # stdlib decoder produces the recursive JSON union
from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError
from referencing import Registry
from referencing.exceptions import NoSuchResource, Unresolvable
from referencing.jsonschema import Schema
JsonValue: TypeAlias = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
MAX_MESSAGE_BYTES: Final = 64 * 1024 * 1024
def refuse_retrieval(uri: str) -> NoReturn:
raise NoSuchResource(ref=uri)
def closed_registry() -> Registry[Schema]:
return Registry(retrieve=refuse_retrieval)
def validate_request(line: bytes) -> Mapping[str, JsonValue]:
try:
request: Final = cast(JsonValue, json.loads(line)) # cast-ok: stdlib JSON decoder returns only JSON values
if not isinstance(request, dict) or not isinstance(schema := request.get("schema"), dict):
return {"valid": False, "message": "invalid validator request"}
validator: Final = Draft202012Validator(schema, registry=closed_registry())
validator.validate(request.get("value")) # pyright: ignore[reportUnknownMemberType] # upstream kwargs are untyped
except (ValidationError, Unresolvable, ValueError, RecursionError) as exc:
return {"valid": False, "message": str(exc)[:4096]}
else:
return {"valid": True, "message": ""}
def main() -> None:
sys.stdout.buffer.write(b"ready\n")
sys.stdout.buffer.flush()
while line := sys.stdin.buffer.readline(MAX_MESSAGE_BYTES + 1):
if len(line) > MAX_MESSAGE_BYTES or not line.endswith(b"\n"):
return
sys.stdout.buffer.write(json.dumps(validate_request(line), ensure_ascii=True).encode() + b"\n")
sys.stdout.buffer.flush()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,546 @@
import ast
import hashlib
import keyword
import re
from collections.abc import Mapping
from itertools import chain
from types import MappingProxyType
from typing import Final
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError, ValidationError
from pydantic import JsonValue
from referencing.exceptions import Unresolvable
from ._validator_worker import closed_registry
from .models import CompiledProgram, JsonSchema, MontyErrorKind, MontyFailure, MontyLimits, PythonProgram
_FORBIDDEN: Final = frozenset(
{
"eval",
"exec",
"compile",
"open",
"__import__",
"globals",
"locals",
"vars",
"getattr",
"setattr",
"delattr",
"breakpoint",
"input",
"help",
}
)
_PREFIX: Final = "_liteflow_"
_MAX_SCHEMA_DEPTH: Final = 64
_SCHEMA_MAPS: Final = frozenset({"properties", "$defs"})
_SCHEMA_VALUES: Final = frozenset({"additionalProperties", "items", "not"})
_SCHEMA_LISTS: Final = frozenset({"prefixItems", "anyOf", "oneOf", "allOf"})
_SCHEMA_ANNOTATIONS: Final = frozenset(
{
"const",
"enum",
"default",
"examples",
"title",
"description",
"deprecated",
"readOnly",
"writeOnly",
"$comment",
"format",
"x-liteflow-record",
"type",
"required",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
"minLength",
"maxLength",
"minItems",
"maxItems",
"minProperties",
"maxProperties",
"$ref",
}
)
_GENERATED_GLOBALS: Final = frozenset({"dataclass", "str", "int", "float", "bool", "list", "dict", "all", "isinstance"})
_SCHEMA_EXPANSION_BUDGET: Final = 65536
class _SchemaWork:
def __init__(self) -> None:
self.units = 0
self.checked: frozenset[tuple[int, str]] = frozenset()
self.keywords: frozenset[tuple[int, str]] = frozenset()
self.contains: Mapping[tuple[int, str], bool] = MappingProxyType({})
def finish_keywords(self, key: tuple[int, str]) -> None:
self.keywords = self.keywords | {key}
def finish_record(self, key: tuple[int, str]) -> None:
self.checked = self.checked | {key}
def finish_contains(self, key: tuple[int, str], value: bool) -> None:
self.contains = MappingProxyType(dict(self.contains) | {key: value})
def spend(self, amount: int = 1) -> None:
self.units += amount
if self.units > _SCHEMA_EXPANSION_BUDGET:
raise ValueError("schema expansion budget exceeded")
def escape_diagnostics(value: str) -> str:
return "".join(
f"\\x{ord(char):02x}" if (ord(char) < 32 and char not in "\n\t") or 127 <= ord(char) <= 159 else char
for char in value
)
def _invalid(rule: str, node: ast.AST) -> MontyFailure:
return MontyFailure(
MontyErrorKind.INVALID_PROGRAM, escape_diagnostics(f"{rule} at line {getattr(node, 'lineno', 1)}")
)
def _safe_name(name: str) -> bool:
return name.isidentifier() and not keyword.iskeyword(name) and "__" not in name and not name.startswith(_PREFIX)
def _module_statement_allowed(statement: ast.stmt) -> bool:
if isinstance(statement, ast.FunctionDef):
return True
if isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant):
return isinstance(statement.value.value, str)
return (
isinstance(statement, ast.ImportFrom)
and statement.module == "__future__"
and statement.level == 0
and all(alias.name == "annotations" and alias.asname is None for alias in statement.names)
)
def _function_error(node: ast.FunctionDef, functions: tuple[ast.FunctionDef, ...]) -> MontyFailure | None:
if node not in functions:
return _invalid("nested functions and closures are forbidden", node)
if node.decorator_list:
return _invalid("decorators are forbidden", node)
if not _safe_name(node.name) or node.name in _GENERATED_GLOBALS:
return _invalid("reserved function name", node)
return None
def _node_error(node: ast.AST, tree: ast.Module, functions: tuple[ast.FunctionDef, ...]) -> MontyFailure | None:
if isinstance(node, (ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda, ast.Global, ast.Nonlocal)):
return _invalid(f"forbidden {type(node).__name__}", node)
if isinstance(node, ast.FunctionDef):
return _function_error(node, functions)
if isinstance(node, (ast.Import, ast.ImportFrom)) and node not in tree.body:
return _invalid("imports inside functions are forbidden", node)
if isinstance(node, ast.Name) and (not _safe_name(node.id) or node.id in _FORBIDDEN):
return _invalid("forbidden builtin or reserved name", node)
if isinstance(node, ast.arg) and not _safe_name(node.arg):
return _invalid("reserved parameter name", node)
if isinstance(node, ast.Attribute) and not _safe_name(node.attr):
return _invalid("dunder or reserved attribute access", node)
return None
def _source_error(tree: ast.Module, program: PythonProgram) -> MontyFailure | None:
for statement in tree.body:
if not _module_statement_allowed(statement):
return _invalid("module body permits only function definitions and future annotations", statement)
functions: Final = tuple(node for node in tree.body if isinstance(node, ast.FunctionDef))
if len({node.name for node in functions}) != len(functions):
return _invalid("duplicate function name", tree)
entries: Final = tuple(node for node in functions if node.name == program.function_name)
if len(entries) != 1:
return _invalid("entry function must exist exactly once", tree)
args: Final = entries[0].args
if args.posonlyargs or args.vararg or args.kwarg or args.kwonlyargs or args.defaults or args.kw_defaults:
return _invalid("entry parameters must be positional without defaults or variadics", entries[0])
if tuple(arg.arg for arg in args.args) != program.parameter_names:
return _invalid("entry parameter order must match parameter_names", entries[0])
return next(
(failure for node in ast.walk(tree) if (failure := _node_error(node, tree, functions)) is not None), None
)
def _mapping(value: JsonValue) -> JsonSchema:
return value if isinstance(value, dict) else {}
def _resolve(schema: JsonSchema, root: JsonSchema, work: _SchemaWork) -> JsonSchema:
work.spend()
ref: Final = schema.get("$ref")
if not isinstance(ref, str):
return schema
def _monty_schema_pointer(value: JsonValue, parts: tuple[str, ...]) -> JsonSchema:
work.spend()
if len(parts) > _MAX_SCHEMA_DEPTH:
return {}
if not parts:
return _mapping(value)
if isinstance(value, dict):
return _monty_schema_pointer(value.get(parts[0].replace("~1", "/").replace("~0", "~")), parts[1:])
if isinstance(value, list) and parts[0].isdigit() and int(parts[0]) < len(value):
return _monty_schema_pointer(value[int(parts[0])], parts[1:])
return {}
return _monty_schema_pointer(root, tuple(ref[2:].split("/")))
def _monty_reference_exists(root: JsonValue, parts: tuple[str, ...], work: _SchemaWork) -> bool:
work.spend()
if len(parts) > _MAX_SCHEMA_DEPTH:
return False
if not parts:
return isinstance(root, (dict, bool))
part: Final = parts[0].replace("~1", "/").replace("~0", "~")
if isinstance(root, dict) and part in root:
return _monty_reference_exists(root[part], parts[1:], work)
if isinstance(root, list) and part.isdigit() and int(part) < len(root):
return _monty_reference_exists(root[int(part)], parts[1:], work)
return False
def _monty_schema_error(
value: JsonValue, root: JsonSchema, work: _SchemaWork, depth: int = 0, seen: frozenset[str] = frozenset()
) -> str | None:
work.spend()
if depth > _MAX_SCHEMA_DEPTH:
return "schema depth limit exceeded"
if not isinstance(value, dict):
return None
for key, item in value.items():
work.spend()
if key not in _SCHEMA_MAPS | _SCHEMA_VALUES | _SCHEMA_LISTS | _SCHEMA_ANNOTATIONS:
return f"unsupported schema keyword {key}"
if key == "$ref" and (not isinstance(item, str) or not item.startswith("#/")):
return "only local #/ JSON references are supported"
if (
key == "$ref"
and isinstance(item, str)
and not _monty_reference_exists(root, tuple(item[2:].split("/")), work)
):
return "local JSON reference target does not exist"
if (
key == "$ref"
and isinstance(item, str)
and (reference_error := _reference_schema_error(value, root, work, depth, seen, item)) is not None
):
return reference_error
return next(
(
error
for child in _children(value, work)
if (error := _monty_schema_error(child, root, work, depth + 1, seen)) is not None
),
None,
)
def _reference_schema_error(
schema: JsonSchema, root: JsonSchema, work: _SchemaWork, depth: int, seen: frozenset[str], ref: str
) -> str | None:
if ref in seen:
return "recursive schema references are unsupported"
cache_key: Final = (id(root), ref)
if cache_key in work.keywords:
return None
result: Final = _monty_schema_error(_resolve(schema, root, work), root, work, depth + 1, seen | {ref})
if result is None:
work.finish_keywords(cache_key)
return result
def _record_shape_error(schema: JsonSchema, root: JsonSchema, work: _SchemaWork) -> str | None:
if schema.get("x-liteflow-record") is not True:
return None
if schema not in tuple(record for _, record in _records(root, work)):
return "records must be defined in root $defs"
if schema.get("type") != "object" or not isinstance(schema.get("properties"), dict):
return "records require fixed object properties"
if schema.get("additionalProperties") is not False:
return "records require additionalProperties false"
if any(not _safe_name(name) for name in _mapping(schema.get("properties"))):
return "record field names must be safe identifiers"
return None
def _record_union_error(schema: JsonSchema, root: JsonSchema, seen: frozenset[str], work: _SchemaWork) -> str | None:
for keyword_name in ("anyOf", "oneOf", "allOf"):
if not isinstance(alternatives := schema.get(keyword_name), list):
continue
if not any(_monty_contains_record(_mapping(item), root, seen, work) for item in alternatives):
continue
if keyword_name != "anyOf" or len(alternatives) != 2:
return "records in unions are supported only as Optional X"
if sum(_mapping(item).get("type") == "null" for item in alternatives) != 1:
return "records in unions are supported only as Optional X"
return None
def _monty_record_error(
schema: JsonSchema, root: JsonSchema, work: _SchemaWork, seen: frozenset[str] = frozenset(), depth: int = 0
) -> str | None:
work.spend()
if depth > _MAX_SCHEMA_DEPTH:
return "schema reference depth limit exceeded"
ref: Final = schema.get("$ref")
if isinstance(ref, str):
if ref in seen:
return "recursive schema references are unsupported"
cache_key: Final = (id(root), ref)
if cache_key in work.checked:
return None
result: Final = _monty_record_error(_resolve(schema, root, work), root, work, seen | {ref}, depth + 1)
if result is None:
work.finish_record(cache_key)
return result
if (shape_error := _record_shape_error(schema, root, work)) is not None:
return shape_error
if (union_error := _record_union_error(schema, root, seen, work)) is not None:
return union_error
return next(
(
error
for child in _children(schema, work)
if (error := _monty_record_error(child, root, work, seen, depth + 1)) is not None
),
None,
)
def _monty_contains_record(
schema: JsonSchema, root: JsonSchema, seen: frozenset[str], work: _SchemaWork, depth: int = 0
) -> bool:
work.spend()
if depth > _MAX_SCHEMA_DEPTH:
return False
ref: Final = schema.get("$ref")
if isinstance(ref, str):
if ref in seen:
return False
cache_key: Final = (id(root), ref)
if cache_key in work.contains:
return work.contains[cache_key]
result: Final = _monty_contains_record(_resolve(schema, root, work), root, seen | {ref}, work, depth + 1)
work.finish_contains(cache_key, result)
return result
if schema.get("x-liteflow-record") is True:
return True
return any(_monty_contains_record(child, root, seen, work, depth + 1) for child in _children(schema, work))
def _children(schema: JsonSchema, work: _SchemaWork) -> tuple[JsonSchema, ...]:
def children_of(key: str, value: JsonValue) -> tuple[JsonSchema, ...]:
work.spend()
if key in {"properties", "$defs"} and isinstance(value, dict):
work.spend(len(value))
return tuple(_mapping(child) for child in value.values())
if key in {"anyOf", "oneOf", "allOf", "prefixItems"} and isinstance(value, list):
work.spend(len(value))
return tuple(_mapping(child) for child in value)
if key in {"items", "additionalProperties", "not"} and isinstance(value, dict):
return (value,)
return ()
return tuple(chain.from_iterable(children_of(key, value) for key, value in schema.items()))
def _records(root: JsonSchema, work: _SchemaWork) -> tuple[tuple[str, JsonSchema], ...]:
work.spend(len(_mapping(root.get("$defs"))))
return tuple(
(name, value)
for name, value in _mapping(root.get("$defs")).items()
if isinstance(value, dict) and value.get("x-liteflow-record") is True
)
def _monty_build(schema: JsonSchema, root: JsonSchema, expression: str, work: _SchemaWork, depth: int = 0) -> str:
work.spend()
result: Final = _build_expression(schema, root, expression, work, depth)
work.spend(len(result.encode("utf-8")))
return result
def _build_expression(schema: JsonSchema, root: JsonSchema, expression: str, work: _SchemaWork, depth: int) -> str:
if depth > _MAX_SCHEMA_DEPTH:
raise ValueError("schema builder depth limit exceeded")
resolved: Final = _resolve(schema, root, work)
ref: Final = schema.get("$ref")
if isinstance(ref, str) and "$ref" in resolved:
return _monty_build(resolved, root, expression, work, depth + 1)
union: Final = resolved.get("anyOf")
if isinstance(union, list) and _monty_contains_record(resolved, root, frozenset(), work):
other: Final = next(_mapping(item) for item in union if _mapping(item).get("type") != "null")
return f"(None if {expression} is None else {_monty_build(other, root, expression, work, depth + 1)})"
if resolved.get("x-liteflow-record") is True:
name: Final = next(name for name, candidate in _records(root, work) if candidate == resolved)
required: Final = resolved.get("required", [])
def field(name: str, child: JsonValue) -> str:
access: Final = (
f"{expression}[{name!r}]"
if isinstance(required, list) and name in required
else f"{expression}.get({name!r})"
)
built: Final = _monty_build(_mapping(child), root, access, work, depth + 1)
return (
f"{name}={built}"
if isinstance(required, list) and name in required
else f"{name}=(None if {access} is None else {built})"
)
return (
f"{name}("
+ ", ".join(field(key, child) for key, child in _mapping(resolved.get("properties")).items())
+ ")"
)
if resolved.get("type") == "array":
variable: Final = f"{_PREFIX}item{depth}"
return f"[{_monty_build(_mapping(resolved.get('items')), root, variable, work, depth + 1)} for {variable} in {expression}]"
if resolved.get("type") == "object" and isinstance(resolved.get("properties"), dict):
fields: Final = _mapping(resolved.get("properties"))
variable_key: Final = f"{_PREFIX}key{depth}"
variable_value: Final = f"{_PREFIX}value{depth}"
branches: Final = "".join(
f"{_monty_build(_mapping(child), root, variable_value, work, depth + 1)} if {variable_key} == {key!r} else "
for key, child in fields.items()
)
fallback: Final = _monty_build(
_mapping(resolved.get("additionalProperties")), root, variable_value, work, depth + 1
)
return (
f"{{{variable_key}: ({branches}{fallback}) for {variable_key}, {variable_value} in {expression}.items()}}"
)
return expression
def _record_class(name: str, schema: JsonSchema, work: _SchemaWork) -> str:
required: Final = schema.get("required", [])
fields: Final = _mapping(schema.get("properties"))
work.spend(len(fields))
mandatory: Final = tuple(key for key in fields if isinstance(required, list) and key in required)
optional: Final = tuple(key for key in fields if key not in mandatory)
return (
f"@dataclass(frozen=True)\nclass {name}:\n"
+ (
"\n".join(f" {key}: object" for key in mandatory)
+ "\n"
+ "\n".join(f" {key}: object = None" for key in optional)
if fields
else " pass"
)
+ "\n"
)
def _source(program: PythonProgram, work: _SchemaWork) -> str:
records: Final = dict(_records(program.input_schema, work) + _records(program.output_schema, work))
classes: Final = "\n".join(_record_class(name, schema, work) for name, schema in records.items())
record_dump: Final = "\n".join(
f" if isinstance(value, {name}):\n return {_PREFIX}dump({{"
+ ", ".join(f"{field!r}: value.{field}" for field in _mapping(schema.get("properties")))
+ "})"
for name, schema in records.items()
)
dumper: Final = f"""def {_PREFIX}dump(value):
{record_dump}
if value is None or isinstance(value, (str, int, float, bool)):
return [True, value]
if isinstance(value, list):
pairs = [{_PREFIX}dump(item) for item in value]
return [all(pair[0] for pair in pairs), [pair[1] for pair in pairs]]
if isinstance(value, dict):
pairs = {{key: {_PREFIX}dump(item) for key, item in value.items()}}
return [all(isinstance(key, str) and pair[0] for key, pair in pairs.items()), {{key: pair[1] for key, pair in pairs.items()}}]
return [False, None]
"""
arguments: Final = ", ".join(
_monty_build(
_mapping(_mapping(program.input_schema.get("properties")).get(name)),
program.input_schema,
f"{_PREFIX}input[{name!r}]",
work,
)
for name in program.parameter_names
)
return (
"from dataclasses import dataclass\n"
+ classes
+ "\n"
+ program.source
+ "\n"
+ dumper
+ f"\n{_PREFIX}dump({program.function_name}({arguments}))"
)
def _schemas_error(program: PythonProgram, tree: ast.Module, work: _SchemaWork) -> MontyFailure | None:
function_names: Final = frozenset(node.name for node in tree.body if isinstance(node, ast.FunctionDef))
validator: Final = Draft202012Validator(Draft202012Validator.META_SCHEMA, registry=closed_registry())
for schema in (program.input_schema, program.output_schema):
if (error := _monty_schema_error(schema, schema, work)) is not None:
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, escape_diagnostics(error))
validator.validate(schema) # pyright: ignore[reportUnknownMemberType] # upstream kwargs are untyped
if (record_error := _monty_record_error(schema, schema, work)) is not None:
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, escape_diagnostics(record_error))
for name, _ in _records(schema, work):
if not _safe_name(name) or name in _GENERATED_GLOBALS or name in function_names:
return MontyFailure(
MontyErrorKind.UNSUPPORTED_SCHEMA, "record class name must be a distinct safe identifier"
)
input_records: Final = dict(_records(program.input_schema, work))
if any(
name in input_records and input_records[name] != schema
for name, schema in _records(program.output_schema, work)
):
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, "conflicting record definitions")
return _input_schema_error(program)
def _input_schema_error(program: PythonProgram) -> MontyFailure | None:
inputs: Final = program.input_schema
required: Final = inputs.get("required")
if (
inputs.get("type") != "object"
or set(_mapping(inputs.get("properties"))) != set(program.parameter_names)
or inputs.get("additionalProperties") is not False
or not isinstance(required, list)
or set(required) != set(program.parameter_names)
):
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, "input schema must require exactly the named parameters")
return None
def compile_program(program: PythonProgram, limits: MontyLimits) -> CompiledProgram | MontyFailure:
try:
encoded: Final = program.source.encode("utf-8")
if len(encoded) > limits.max_source_bytes:
return MontyFailure(MontyErrorKind.INVALID_PROGRAM, "source byte limit exceeded at line 1")
if hashlib.sha256(encoded).hexdigest() != program.source_sha256:
return MontyFailure(MontyErrorKind.INVALID_PROGRAM, "source_sha256 mismatch at line 1")
if re.search(r"(?m)^\s*#\s*///\s*script\b", program.source):
return MontyFailure(MontyErrorKind.INVALID_PROGRAM, "PEP 723 script blocks forbidden at line 1")
tree: Final = ast.parse(program.source)
if (failure := _source_error(tree, program)) is not None:
return failure
work: Final = _SchemaWork()
if (schema_failure := _schemas_error(program, tree, work)) is not None:
return schema_failure
generated: Final = _source(program, work)
work.spend(len(generated.encode("utf-8")))
return CompiledProgram(program, generated, program.input_schema, program.output_schema)
except SyntaxError as exc:
return MontyFailure(
MontyErrorKind.INVALID_PROGRAM, escape_diagnostics(f"syntax error at line {exc.lineno}: {exc.msg}")
)
except (SchemaError, ValidationError, Unresolvable, ValueError, RecursionError, StopIteration) as exc:
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, escape_diagnostics(str(exc)))

View file

@ -0,0 +1,86 @@
import keyword
from dataclasses import dataclass
from enum import StrEnum
from typing import Annotated, Literal, TypeAlias
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, JsonValue, StringConstraints
JsonSchema: TypeAlias = dict[str, JsonValue]
def _identifier(value: str) -> str:
if not value.isidentifier() or keyword.iskeyword(value):
raise ValueError("must be a Python identifier")
return value
ProgramName: TypeAlias = Annotated[str, StringConstraints(min_length=1, max_length=128), AfterValidator(_identifier)]
class PythonProgram(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
contract_version: Literal["1"]
source: str
function_name: ProgramName
parameter_names: Annotated[tuple[ProgramName, ...], Field(min_length=1, max_length=32)]
input_schema: JsonSchema
output_schema: JsonSchema
source_sha256: Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{64}$")]
class MontyErrorKind(StrEnum):
INVALID_PROGRAM = "INVALID_PROGRAM"
UNSUPPORTED_SCHEMA = "UNSUPPORTED_SCHEMA"
INPUT_INVALID = "INPUT_INVALID"
INPUT_TOO_LARGE = "INPUT_TOO_LARGE"
OUTPUT_INVALID = "OUTPUT_INVALID"
OUTPUT_TOO_LARGE = "OUTPUT_TOO_LARGE"
RUNTIME_ERROR = "RUNTIME_ERROR"
TIMEOUT = "TIMEOUT"
MEMORY_LIMIT = "MEMORY_LIMIT"
RECURSION_LIMIT = "RECURSION_LIMIT"
STDOUT_LIMIT = "STDOUT_LIMIT"
WORKER_CRASHED = "WORKER_CRASHED"
QUEUE_FULL = "QUEUE_FULL"
PREDICATE_NOT_BOOL = "PREDICATE_NOT_BOOL"
CANCELLED = "CANCELLED"
RUNTIME_UNAVAILABLE = "RUNTIME_UNAVAILABLE"
@dataclass(frozen=True, slots=True)
class MontyFailure:
kind: MontyErrorKind
message: str
@dataclass(frozen=True, slots=True)
class MontySuccess:
output: JsonValue
stdout: str
duration_ms: int
@dataclass(frozen=True, slots=True)
class CompiledProgram:
program: PythonProgram
sandbox_source: str
input_schema: JsonSchema
output_schema: JsonSchema
class MontyLimits(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid", allow_inf_nan=False)
max_source_bytes: int = Field(default=64 * 1024, gt=0, le=1024 * 1024)
max_input_bytes: int = Field(default=1024 * 1024, gt=0, le=16 * 1024 * 1024)
max_output_bytes: int = Field(default=1024 * 1024, gt=0, le=16 * 1024 * 1024)
max_input_depth: int = Field(default=64, gt=0, le=128)
max_output_depth: int = Field(default=64, gt=0, le=128)
max_memory: int = Field(default=64 * 1024 * 1024, gt=0, le=1024 * 1024 * 1024)
worker_address_space_bytes: int = Field(default=512 * 1024 * 1024, gt=0, le=8 * 1024 * 1024 * 1024)
feed_seconds: float = Field(default=5, gt=0, le=60)
max_recursion_depth: int = Field(default=200, gt=0, le=1000)
max_stdout_bytes: int = Field(default=64 * 1024, gt=0, le=1024 * 1024)
max_concurrent_workers: int = Field(default=4, gt=0, le=64)
max_queued_waiters: int = Field(default=16, gt=0, le=1024)

View file

@ -0,0 +1,318 @@
from __future__ import annotations
import asyncio
import base64
import hashlib
import importlib.metadata
import json
import math
import os
import signal
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Final
from pydantic import JsonValue, TypeAdapter
from pydantic import ValidationError as PydanticValidationError
from .compiler import escape_diagnostics
from .models import CompiledProgram, JsonSchema, MontyErrorKind, MontyFailure, MontyLimits, MontySuccess
from .validation import ValidatorProcess
if TYPE_CHECKING:
from pydantic_monty import AsyncMonty, AsyncMontySession, CollectString, MontyRuntimeError
_JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
@dataclass(frozen=True, slots=True)
class BinaryArtifact:
path: Path
sha256: str
def installed_binary() -> BinaryArtifact | MontyFailure:
try:
distribution: Final = importlib.metadata.distribution("pydantic-monty-runtime")
candidates: Final = tuple(file for file in distribution.files or () if file.name in {"monty", "monty.exe"})
if len(candidates) != 1:
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "runtime binary missing or ambiguous")
file: Final = candidates[0]
if file.hash is None or file.hash.mode != "sha256":
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "runtime binary has no SHA-256 RECORD hash")
digest: Final = base64.urlsafe_b64decode(file.hash.value + "=" * (-len(file.hash.value) % 4)).hex()
return BinaryArtifact(Path(str(distribution.locate_file(file))).resolve(), digest)
except (importlib.metadata.PackageNotFoundError, OSError, ValueError) as exc:
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, escape_diagnostics(str(exc)))
def _monty_json_issue(value: JsonValue, depth: int, max_depth: int) -> MontyErrorKind | None:
if depth > max_depth:
return MontyErrorKind.INPUT_TOO_LARGE
if value is None or type(value) in (str, int, bool):
return None
if type(value) is float:
return None if math.isfinite(value) else MontyErrorKind.INPUT_INVALID
if type(value) is list:
return next(
(issue for item in value if (issue := _monty_json_issue(item, depth + 1, max_depth)) is not None), None
)
if type(value) is dict:
if any(type(key) is not str for key in value):
return MontyErrorKind.INPUT_INVALID
return next(
(
child_issue
for item in value.values()
if (child_issue := _monty_json_issue(item, depth + 1, max_depth)) is not None
),
None,
)
return MontyErrorKind.INPUT_INVALID
def validate_json(value: object, *, max_bytes: int, max_depth: int, output: bool = False) -> JsonValue | MontyFailure:
invalid: Final = MontyErrorKind.OUTPUT_INVALID if output else MontyErrorKind.INPUT_INVALID
too_large: Final = MontyErrorKind.OUTPUT_TOO_LARGE if output else MontyErrorKind.INPUT_TOO_LARGE
try:
parsed: Final = _JSON.validate_python(value, strict=True)
issue: Final = _monty_json_issue(parsed, 0, max_depth)
if issue is not None:
return MontyFailure(
too_large if issue == MontyErrorKind.INPUT_TOO_LARGE else invalid, "JSON depth or type invalid"
)
encoded: Final = json.dumps(parsed, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")
if len(encoded) > max_bytes:
return MontyFailure(too_large, "JSON byte limit exceeded")
except PydanticValidationError as exc:
if '"type":"recursion_loop"' in exc.json():
return MontyFailure(too_large, "JSON depth limit exceeded")
return MontyFailure(invalid, escape_diagnostics(str(exc))[:4096])
except (ValueError, OverflowError, RecursionError) as exc:
return MontyFailure(invalid, escape_diagnostics(str(exc))[:4096])
else:
return parsed
def _kill(session: AsyncMontySession) -> None:
pid: Final = session.worker_pid
if pid is None:
return
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
return
def _worker_limit(session: AsyncMontySession, limits: MontyLimits) -> MontyFailure | None:
if sys.platform != "linux":
return None
import resource
pid: Final = session.worker_pid
if pid is None:
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "worker PID unavailable")
try:
resource.prlimit(
pid, resource.RLIMIT_AS, (limits.worker_address_space_bytes, limits.worker_address_space_bytes)
)
except (OSError, ValueError) as exc:
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, escape_diagnostics(str(exc)))
return None
def _runtime_failure(exc: MontyRuntimeError, collector: CollectString, limits: MontyLimits) -> MontyFailure:
error: Final = exc.exception()
message: Final = escape_diagnostics(exc.display())[:4096]
if isinstance(error, TimeoutError):
return MontyFailure(MontyErrorKind.TIMEOUT, message)
if isinstance(error, MemoryError):
kind: Final = (
MontyErrorKind.STDOUT_LIMIT
if len(collector.output.encode("utf-8")) >= limits.max_stdout_bytes and not exc.traceback()
else MontyErrorKind.MEMORY_LIMIT
)
return MontyFailure(kind, message)
if isinstance(error, RecursionError):
return MontyFailure(MontyErrorKind.RECURSION_LIMIT, message)
return MontyFailure(MontyErrorKind.RUNTIME_ERROR, message)
class MontyRuntime:
def __init__(self, limits: MontyLimits, *, binary: BinaryArtifact | None = None) -> None:
self.limits = limits
self._binary = binary
self._pool: AsyncMonty | None = None
self._slots = asyncio.Semaphore(limits.max_concurrent_workers)
self._admitted = 0
self._validators = tuple(ValidatorProcess() for _ in range(limits.max_concurrent_workers))
async def start(self) -> MontyFailure | None:
if self._pool is not None:
return None
try:
from pydantic_monty import AsyncMonty
artifact: Final = self._binary if self._binary is not None else installed_binary()
if isinstance(artifact, MontyFailure):
return artifact
if hashlib.sha256(artifact.path.read_bytes()).hexdigest() != artifact.sha256:
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "runtime binary SHA-256 mismatch")
pool: Final = AsyncMonty(
binary_path=artifact.path,
min_processes=0,
max_processes=self.limits.max_concurrent_workers,
max_checkouts_per_worker=1,
checkout_timeout=self.limits.feed_seconds,
request_timeout=self.limits.feed_seconds + 1,
feed_duration_limit_grace=0.1,
)
await pool.__aenter__()
validators: Final = await asyncio.gather(*(worker.start() for worker in self._validators))
if (failure := next((result for result in validators if result is not None), None)) is not None:
await asyncio.gather(*(worker.close() for worker in self._validators))
await pool.__aexit__(None, None, None)
return failure
self._pool = pool
except (ImportError, OSError, ValueError, RuntimeError) as exc:
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, escape_diagnostics(str(exc)))
else:
return None
async def close(self) -> None:
pool: Final = self._pool
self._pool = None
await asyncio.gather(*(worker.close() for worker in self._validators))
if pool is not None:
await pool.__aexit__(None, None, None)
async def run_program(
self, compiled: CompiledProgram, input: object, *, deadline_monotonic: float
) -> MontySuccess | MontyFailure:
started: Final = time.monotonic()
if started >= deadline_monotonic:
return MontyFailure(MontyErrorKind.TIMEOUT, "node deadline expired")
if self._pool is None:
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "Monty runtime is not started")
if self._admitted >= self.limits.max_concurrent_workers + self.limits.max_queued_waiters:
return MontyFailure(MontyErrorKind.QUEUE_FULL, "Monty execution queue is full")
self._admitted += 1
try:
async with asyncio.timeout_at(asyncio.get_running_loop().time() + deadline_monotonic - time.monotonic()):
async with self._slots:
worker: Final = next(worker for worker in self._validators if not worker.busy)
worker.busy = True
try:
validated: Final = await self._validate(
input, compiled.input_schema, worker, deadline_monotonic
)
if isinstance(validated, MontyFailure):
return validated
return await self._execute(compiled, validated, deadline_monotonic, started, worker)
except asyncio.CancelledError:
await worker.close()
raise
finally:
worker.busy = False
except TimeoutError:
return MontyFailure(MontyErrorKind.TIMEOUT, "node deadline expired")
finally:
self._admitted -= 1
async def _execute(
self, compiled: CompiledProgram, input: JsonValue, deadline: float, started: float, worker: ValidatorProcess
) -> MontySuccess | MontyFailure:
from pydantic_monty import CollectString, MontyCrashedError, MontyRuntimeError, MontySyntaxError, ResourceLimits
pool: Final = self._pool
if pool is None:
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "Monty runtime is closed")
remaining: Final = deadline - time.monotonic()
if remaining <= 0:
return MontyFailure(MontyErrorKind.TIMEOUT, "node deadline expired")
collector: Final = CollectString(max_bytes=self.limits.max_stdout_bytes)
session: Final = pool.checkout(
script_name="workflow.py",
limits=ResourceLimits(
max_feed_duration_secs=min(self.limits.feed_seconds, remaining),
max_memory=self.limits.max_memory,
max_recursion_depth=self.limits.max_recursion_depth,
),
os_policy={"sleep": "zero"},
)
try:
async with session:
try:
if (limit_failure := _worker_limit(session, self.limits)) is not None:
return limit_failure
result: Final = _JSON.validate_python(
await session.feed_run(
compiled.sandbox_source,
inputs={"_liteflow_input": input},
print_callback=collector,
),
strict=True,
)
return await self._output(result, compiled, collector.output, deadline, started, worker)
except asyncio.CancelledError:
_kill(session)
raise
except MontyRuntimeError as exc:
return _runtime_failure(exc, collector, self.limits)
except MontyCrashedError as exc:
return MontyFailure(
MontyErrorKind.TIMEOUT if exc.timed_out else MontyErrorKind.WORKER_CRASHED,
escape_diagnostics(str(exc))[:4096],
)
except MontySyntaxError as exc:
return MontyFailure(MontyErrorKind.INVALID_PROGRAM, escape_diagnostics(str(exc))[:4096])
except (ValueError, OSError) as exc:
return MontyFailure(MontyErrorKind.RUNTIME_ERROR, escape_diagnostics(str(exc))[:4096])
async def _output(
self,
result: object,
compiled: CompiledProgram,
stdout: str,
deadline: float,
started: float,
worker: ValidatorProcess,
) -> MontySuccess | MontyFailure:
try:
envelope: Final = _JSON.validate_python(result, strict=True)
except ValueError:
return MontyFailure(MontyErrorKind.OUTPUT_INVALID, "return value is not strict JSON")
if not isinstance(envelope, list) or len(envelope) != 2 or envelope[0] is not True:
return MontyFailure(MontyErrorKind.OUTPUT_INVALID, "return value is not strict JSON")
output: Final = await self._validate(envelope[1], compiled.output_schema, worker, deadline, output=True)
if isinstance(output, MontyFailure):
return output
if time.monotonic() >= deadline:
return MontyFailure(MontyErrorKind.TIMEOUT, "node deadline expired during validation")
return MontySuccess(output, escape_diagnostics(stdout), int((time.monotonic() - started) * 1000))
async def _validate(
self, value: object, schema: JsonSchema, worker: ValidatorProcess, deadline: float, *, output: bool = False
) -> JsonValue | MontyFailure:
parsed: Final = validate_json(
value,
max_bytes=self.limits.max_output_bytes if output else self.limits.max_input_bytes,
max_depth=self.limits.max_output_depth if output else self.limits.max_input_depth,
output=output,
)
if isinstance(parsed, MontyFailure):
return parsed
failure: Final = await worker.validate(parsed, schema, deadline=deadline, output=output)
return failure if failure is not None else parsed
async def evaluate_predicate(
self, compiled: CompiledProgram, input: object, *, deadline_monotonic: float
) -> bool | MontyFailure:
result: Final = await self.run_program(compiled, input, deadline_monotonic=deadline_monotonic)
if isinstance(result, MontyFailure):
return result
if type(result.output) is not bool:
return MontyFailure(MontyErrorKind.PREDICATE_NOT_BOOL, "predicate must return exactly bool")
return result.output

View file

@ -0,0 +1,98 @@
from __future__ import annotations
import asyncio
import json
import sys
import time
from pathlib import Path
from typing import Final
from pydantic import BaseModel, ConfigDict, JsonValue, StrictBool, StrictStr
from ._validator_worker import MAX_MESSAGE_BYTES
from .compiler import escape_diagnostics
from .models import JsonSchema, MontyErrorKind, MontyFailure
class _Response(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
valid: StrictBool
message: StrictStr
class ValidatorProcess:
def __init__(self) -> None:
self.busy = False
self.process: asyncio.subprocess.Process | None = None
async def start(self) -> MontyFailure | None:
if self.process is not None and self.process.returncode is None:
return None
try:
async with asyncio.timeout(5):
self.process = await asyncio.create_subprocess_exec(
sys.executable,
"-I",
str(Path(__file__).with_name("_validator_worker.py")),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
env={},
limit=65536,
)
if self.process.stdout is None or await self.process.stdout.readline() != b"ready\n":
await self.close()
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "validator startup failed")
except asyncio.CancelledError:
await self.close()
raise
except (OSError, ValueError, TimeoutError) as exc:
await self.close()
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, escape_diagnostics(str(exc)))
return None
async def close(self) -> None:
process: Final = self.process
self.process = None
if process is not None:
if process.returncode is None:
try:
process.kill()
except ProcessLookupError:
pass
await process.wait()
async def validate(
self, value: JsonValue, schema: JsonSchema, *, deadline: float, output: bool = False
) -> MontyFailure | None:
try:
async with asyncio.timeout(max(0, deadline - time.monotonic())):
if (failure := await self.start()) is not None:
return failure
process: Final = self.process
if process is None or process.stdin is None or process.stdout is None:
return MontyFailure(MontyErrorKind.WORKER_CRASHED, "validator pipes unavailable")
request: Final = json.dumps({"value": value, "schema": schema}, ensure_ascii=True).encode() + b"\n"
if len(request) > MAX_MESSAGE_BYTES:
return MontyFailure(
MontyErrorKind.OUTPUT_TOO_LARGE if output else MontyErrorKind.INPUT_TOO_LARGE,
"validator request byte limit exceeded",
)
process.stdin.write(request)
await process.stdin.drain()
response: Final = _Response.model_validate_json(await process.stdout.readline())
if not response.valid:
return MontyFailure(
MontyErrorKind.OUTPUT_INVALID if output else MontyErrorKind.INPUT_INVALID,
escape_diagnostics(response.message)[:4096],
)
except asyncio.CancelledError:
await self.close()
raise
except TimeoutError:
await self.close()
return MontyFailure(MontyErrorKind.TIMEOUT, "node deadline expired during schema validation")
except (OSError, ValueError, RecursionError) as exc:
await self.close()
return MontyFailure(MontyErrorKind.WORKER_CRASHED, escape_diagnostics(str(exc))[:4096])
return None

View file

@ -56,6 +56,7 @@ Documentation = "https://docs.litellm.ai"
[project.optional-dependencies]
switchyard = ["nemo-switchyard==0.4.0a1"]
proxy = [
"pydantic-monty==1.4.0",
"gunicorn>=23.0.0,<24.0",
"uvicorn>=0.33.0,<1.0",
"granian>=2.7.4,<3.0; python_version < '3.15'",
@ -258,6 +259,7 @@ admin-mcp = [
"litellm-admin-mcp @ https://github.com/BerriAI/liteadmin-mcp/archive/31fb096904ae05684c0779110a93b135b5601182.tar.gz#sha256=5803a20a00a18177f4591706956f31be311879543049b8b38143ad90bb1cb0a6 ; python_version >= '3.12'",
]
proxy-dev = [
"pydantic-monty==1.4.0",
{ include-group = "admin-mcp" },
"prisma==0.11.0",
"hypercorn==0.17.3",

View file

@ -2,6 +2,13 @@ import ast
import os
IGNORE_FUNCTIONS = [
"_monty_schema_pointer", # JSON pointer length capped at 64 before descending through finite JsonValue containers.
"_monty_reference_exists", # JSON pointer length capped at 64 before descending through finite JsonValue containers.
"_monty_schema_error", # _MAX_SCHEMA_DEPTH=64, returns UNSUPPORTED_SCHEMA at the cap.
"_monty_record_error", # _MAX_SCHEMA_DEPTH=64 and seen-reference cycle guard, fails closed at either bound.
"_monty_contains_record", # _MAX_SCHEMA_DEPTH=64 and seen-reference cycle guard, bounded record discovery only.
"_monty_build", # _MAX_SCHEMA_DEPTH=64, compile_program maps the bound violation to UNSUPPORTED_SCHEMA.
"_monty_json_issue", # Positive max_depth is bounded to 128 by MontyLimits, returns *_TOO_LARGE at the cap.
"_json_cost", # bounded at depth 32 and consumed under byte/node limits.
"detached", # router_config: copies only the plain containers of the router's own constructor arguments, once per build or changeset
"_format_type",

View file

@ -26,7 +26,7 @@ directory that a `GROUPS` entry in `run.py` selects. There is no manifest and no
tests. A product bug the test exposes is `pytest.skip("BUG: <symptom>")` at the top of the body, not a
fix in the test and not a deletion. Needs no network, proxy, DB or Redis: `tests/unit`
CircleCI's integration groups run every file here except the OCI and sandbox tests, which no CI runs, and
CircleCI's integration groups run every file here except the OCI tests, which no CI runs, and
the files in `GITHUB_FILES` in `run.py`. `test-integration.yml` runs the same groups through the same
`run_integration.sh` on GitHub Actions, on main every three hours from 8am to 8pm Pacific Monday to Saturday,
and on a pull request when someone adds the `run-ci` label. Neither runs every pull request, so a new file here
@ -41,6 +41,11 @@ together with its dedicated job, and list a new dedicated GitHub workflow with t
fails a file here that a GitHub workflow runs unless it is in `GITHUB_FILES` and that workflow owns its
directory (#46780)
The Monty sandbox tests are an explicit exception. `test-integration.yml` owns `sandbox/` and runs
`uv run --no-sync pytest tests/integration/sandbox/test_monty_runtime.py -q` in its focused Monty job
on pull requests, scheduled runs and manual `all` or `monty` runs. It installs the proxy extra and
needs no gateway, database, Redis or credentials. Keep subprocess and timing tests here, not in unit tests
## Fixtures that boot a proxy
Scope them to the module or class, or pin a session one to a single worker with an `xdist_group` mark

View file

@ -4,6 +4,8 @@ These tests exercise a running gateway, PostgreSQL and Redis with an owned local
The ROI database contracts in `database/test_roi_observed.py` run as plain pytest outside `run_integration.sh` in CircleCI's `roi-database` Postgres job. The job uploads coverage with the `roi-postgres` flag. These contracts own temporary databases and script only the external provider transport
The `Monty sandbox` job in `test-integration.yml` runs `uv run --no-sync pytest tests/integration/sandbox/test_monty_runtime.py -q` with the proxy extra on pull requests, schedules and manual `all` or `monty` runs. It exercises real Monty and isolated JSON Schema validator processes without external services. Sandbox tests are outside the CircleCI groups
The Redis cluster IAM contracts in `sdk/test_redis_cluster_iam_auth.py` start their own `redis-server` and mock the cloud credential clients, so CircleCI's `sdk` group installs `redis-server` before it runs them
The `cost` group is driven by the `CostTrackingTestCase` literals under `cost_calculation/cases/<provider>.py` (gathered by `cost_calculation/catalog.py`), with the scripted cost map in `cost_calculation/cost_map.py`. Each case has a name, contract ID, cost-map model, optional deployment overrides, request body, tagged response and exact or recount expectations. Request bodies use `$MODEL` for the registered proxy model, while responses use `$REQUEST_ID` for the per-run scenario ID. To add a case, add a cost-map entry when the model is new, add the request body and exact provider response data, and add hand-computed expected values. The upstream serves each stored response for any path under `/<scenario_id>`, while the test-owned cost map is served over loopback through `LITELLM_MODEL_COST_MAP_URL`

View file

@ -27,6 +27,7 @@ GROUPS: Final = MappingProxyType(
)
GITHUB_FILES: Final = frozenset(
{
"tests/integration/sandbox/test_monty_runtime.py",
"tests/integration/database/test_roi_observed.py",
"tests/integration/database/image/test_component_image_serves_offline.py",
"tests/integration/database/image/test_image_admin_mcp.py",

View file

@ -0,0 +1,614 @@
import asyncio
import hashlib
import os
import time
from collections.abc import AsyncIterator
from dataclasses import replace
from pathlib import Path
from typing import Final
import psutil
import pytest
import pytest_asyncio
from openai.types.chat import ChatCompletionMessageParam
from pydantic import BaseModel, ConfigDict, TypeAdapter
from litellm.proxy.workflows.monty.compiler import compile_program
from litellm.proxy.workflows.monty.models import (
CompiledProgram,
JsonSchema,
MontyErrorKind,
MontyFailure,
MontyLimits,
MontySuccess,
PythonProgram,
)
from litellm.proxy.workflows.monty.runtime import BinaryArtifact, MontyRuntime, installed_binary
from tests.integration._support.process import stop_processes
pytest.importorskip("pydantic_monty")
def _compile(
source: str,
output: JsonSchema | None = None,
argument: JsonSchema | None = None,
definitions: JsonSchema | None = None,
) -> CompiledProgram:
program: Final = PythonProgram(
contract_version="1",
source=source,
function_name="transform",
parameter_names=("value",),
input_schema={
"type": "object",
"properties": {"value": argument if argument is not None else {}},
"required": ["value"],
"additionalProperties": False,
"$defs": definitions or {},
},
output_schema={**(output or {}), "$defs": definitions or {}},
source_sha256=hashlib.sha256(source.encode()).hexdigest(),
)
result: Final = compile_program(program, MontyLimits())
assert isinstance(result, CompiledProgram), result
return result
@pytest_asyncio.fixture(loop_scope="function")
async def runtime() -> AsyncIterator[MontyRuntime]:
instance: Final = MontyRuntime(MontyLimits(max_concurrent_workers=1, max_queued_waiters=1))
assert await instance.start() is None
try:
yield instance
finally:
await instance.close()
async def _run(runtime: MontyRuntime, compiled: CompiledProgram, value: object) -> MontySuccess | MontyFailure:
return await runtime.run_program(compiled, {"value": value}, deadline_monotonic=time.monotonic() + 10)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"value",
[
{"__class__": "os.system"},
{"type": "function", "callable": "os.system"},
"Bearer sk-test-fake",
"${OPENAI_API_KEY}",
"https://example.invalid/action",
[],
None,
],
)
async def test_model_output_remains_inert_json(runtime: MontyRuntime, value: object) -> None:
result: Final = await _run(runtime, _compile("def transform(value): return value"), value)
assert isinstance(result, MontySuccess), result
assert result.output == value
@pytest.mark.asyncio
async def test_typed_json_transform_and_escaped_stdout(runtime: MontyRuntime) -> None:
compiled: Final = _compile(
"def transform(value):\n print('\\x1b[31m')\n return {'n': value['n'] + 1}",
{"type": "object", "properties": {"n": {"type": "integer"}}, "required": ["n"]},
)
result: Final = await _run(runtime, compiled, {"n": 2})
assert isinstance(result, MontySuccess), result
assert result.output == {"n": 3}
assert result.stdout == "\\x1b[31m\n"
_RECORDS: Final[JsonSchema] = {
"Expense": {
"type": "object",
"x-liteflow-record": True,
"additionalProperties": False,
"properties": {
"amount_cents": {"type": "integer", "minimum": 0},
"currency": {"type": "string"},
"receipt_present": {"type": "boolean"},
},
"required": ["amount_cents", "currency", "receipt_present"],
},
}
_EXPENSE: Final[JsonSchema] = {"$ref": "#/$defs/Expense"}
@pytest.mark.asyncio
@pytest.mark.parametrize("receipt,amount,review", [(True, 100, False), (False, 100, True), (True, 10001, True)])
async def test_expense_normalize_condition_and_both_branches(
runtime: MontyRuntime, receipt: bool, amount: int, review: bool
) -> None:
normalize: Final = _compile(
"def transform(value: Expense) -> Expense:\n return Expense(amount_cents=value.amount_cents, currency=value.currency.strip().upper(), receipt_present=value.receipt_present)",
_EXPENSE,
_EXPENSE,
_RECORDS,
)
normalized: Final = await _run(
runtime, normalize, {"amount_cents": amount, "currency": " usd ", "receipt_present": receipt}
)
assert isinstance(normalized, MontySuccess), normalized
assert normalized.output == {"amount_cents": amount, "currency": "USD", "receipt_present": receipt}
condition: Final = _compile(
"def transform(value: Expense) -> bool:\n return not value.receipt_present or value.currency != 'USD' or value.amount_cents > 10000",
{"type": "boolean"},
_EXPENSE,
_RECORDS,
)
assert (
await runtime.evaluate_predicate(
condition, {"value": normalized.output}, deadline_monotonic=time.monotonic() + 10
)
is review
)
status: Final = "review_required" if review else "ready_for_processing"
branch: Final = _compile(
f"def transform(value): return {{'status': {status!r}, 'amount_cents': value.amount_cents, 'currency': value.currency}}",
{"type": "object"},
_EXPENSE,
_RECORDS,
)
result: Final = await _run(runtime, branch, normalized.output)
assert isinstance(result, MontySuccess), result
assert result.output == {"status": status, "amount_cents": amount, "currency": "USD"}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"source",
[
"def transform(value): return Expense(amount_cents=1, currency='USD', receipt_present=True)",
"def transform(value): return {'amount_cents':1, 'currency':'USD', 'receipt_present':True}",
],
)
async def test_record_and_equal_dict_are_equivalent(runtime: MontyRuntime, source: str) -> None:
result: Final = await _run(runtime, _compile(source, _EXPENSE, definitions=_RECORDS), None)
assert isinstance(result, MontySuccess), result
assert result.output == {"amount_cents": 1, "currency": "USD", "receipt_present": True}
@pytest.mark.asyncio
@pytest.mark.parametrize("value", [None, {"amount_cents": 7, "currency": "USD", "receipt_present": True}])
async def test_optional_record(runtime: MontyRuntime, value: object) -> None:
optional: Final[JsonSchema] = {"anyOf": [_EXPENSE, {"type": "null"}]}
result: Final = await _run(
runtime,
_compile(
"def transform(value): return None if value is None else value.amount_cents",
argument=optional,
definitions=_RECORDS,
),
value,
)
assert isinstance(result, MontySuccess), result
assert result.output == (None if value is None else 7)
@pytest.mark.asyncio
async def test_list_of_records(runtime: MontyRuntime) -> None:
result: Final = await _run(
runtime,
_compile(
"def transform(value): return [item.currency for item in value]",
argument={"type": "array", "items": _EXPENSE},
definitions=_RECORDS,
),
[{"amount_cents": 7, "currency": "USD", "receipt_present": True}],
)
assert isinstance(result, MontySuccess), result
assert result.output == ["USD"]
@pytest.mark.asyncio
@pytest.mark.parametrize("value", [1, "true", None])
async def test_predicate_requires_exact_bool(runtime: MontyRuntime, value: object) -> None:
result: Final = await runtime.evaluate_predicate(
_compile("def transform(value): return value"), {"value": value}, deadline_monotonic=time.monotonic() + 10
)
assert isinstance(result, MontyFailure)
assert result.kind == MontyErrorKind.PREDICATE_NOT_BOOL
@pytest.mark.asyncio
@pytest.mark.parametrize(
"source,schema,kind",
[
("def transform(value): return -1", {"type": "integer", "minimum": 0}, MontyErrorKind.OUTPUT_INVALID),
("def transform(value): return 'wrong'", {"type": "integer"}, MontyErrorKind.OUTPUT_INVALID),
("def helper(x): return x\ndef transform(value): return helper", {}, MontyErrorKind.OUTPUT_INVALID),
("def transform(value): return 'x' * 1100000", {}, MontyErrorKind.OUTPUT_TOO_LARGE),
("def transform(value):\n while True: pass", {}, MontyErrorKind.TIMEOUT),
("def transform(value): return [0] * 1000000000", {}, MontyErrorKind.MEMORY_LIMIT),
("def transform(value): return transform(value)", {}, MontyErrorKind.RECURSION_LIMIT),
("def transform(value): print('x' * 100000)", {}, MontyErrorKind.STDOUT_LIMIT),
("def transform(value): return 1 / 0", {}, MontyErrorKind.RUNTIME_ERROR),
],
)
async def test_resource_and_output_failures(
runtime: MontyRuntime, source: str, schema: JsonSchema, kind: MontyErrorKind
) -> None:
result: Final = await _run(runtime, _compile(source, schema), None)
assert isinstance(result, MontyFailure), result
assert result.kind == kind, result
@pytest.mark.asyncio
@pytest.mark.parametrize(
"value,kind",
[
({1: "not JSON"}, MontyErrorKind.INPUT_INVALID),
(float("nan"), MontyErrorKind.INPUT_INVALID),
("x" * 1100000, MontyErrorKind.INPUT_TOO_LARGE),
],
)
async def test_input_failures(runtime: MontyRuntime, value: object, kind: MontyErrorKind) -> None:
result: Final = await _run(runtime, _compile("def transform(value): return value"), value)
assert isinstance(result, MontyFailure), result
assert result.kind == kind
@pytest.mark.asyncio
async def test_expired_deadline(runtime: MontyRuntime) -> None:
result: Final = await runtime.run_program(
_compile("def transform(value): return value"), {"value": 1}, deadline_monotonic=0
)
assert isinstance(result, MontyFailure)
assert result.kind == MontyErrorKind.TIMEOUT
@pytest.mark.asyncio
async def test_binary_digest_mismatch_fails_closed() -> None:
artifact: Final = installed_binary()
assert isinstance(artifact, BinaryArtifact)
runtime: Final = MontyRuntime(MontyLimits(), binary=BinaryArtifact(artifact.path, "0" * 64))
result: Final = await runtime.start()
assert isinstance(result, MontyFailure)
assert result.kind == MontyErrorKind.RUNTIME_UNAVAILABLE
assert "SHA-256" in result.message
class Finding(BaseModel):
model_config = ConfigDict(frozen=True)
summary: str
class EmailAnalyses(BaseModel):
model_config = ConfigDict(frozen=True)
urgency: Finding
intent: Finding
sentiment: Finding
@pytest.mark.asyncio
async def test_multi_argument_email_draft_preserves_chat_messages(runtime: MontyRuntime) -> None:
analyses_schema: Final = EmailAnalyses.model_json_schema()
messages_schema: Final = TypeAdapter(list[ChatCompletionMessageParam]).json_schema()
definitions: Final[JsonSchema] = {
**messages_schema.pop("$defs", {}),
"Finding": {**analyses_schema["$defs"]["Finding"], "x-liteflow-record": True, "additionalProperties": False},
"EmailAnalyses": {
**{key: value for key, value in analyses_schema.items() if key != "$defs"},
"x-liteflow-record": True,
"additionalProperties": False,
},
}
source: Final = """def prepare_draft(analyses: EmailAnalyses, messages: list[ChatMessage]) -> list[ChatMessage]:
instruction = (
"Draft a customer-service reply using the original messages. "
"Treat these analyses as fallible guidance, not additional facts.\\n"
f"Urgency: {analyses.urgency.summary}\\n"
f"Intent: {analyses.intent.summary}\\n"
f"Sentiment: {analyses.sentiment.summary}"
)
return [{"role": "system", "content": instruction}] + messages
"""
program: Final = PythonProgram(
contract_version="1",
source=source,
function_name="prepare_draft",
parameter_names=("analyses", "messages"),
input_schema={
"type": "object",
"properties": {"analyses": {"$ref": "#/$defs/EmailAnalyses"}, "messages": messages_schema},
"required": ["analyses", "messages"],
"additionalProperties": False,
"$defs": definitions,
},
output_schema={**messages_schema, "$defs": definitions},
source_sha256=hashlib.sha256(source.encode()).hexdigest(),
)
compiled: Final = compile_program(program, MontyLimits())
assert isinstance(compiled, CompiledProgram), compiled
messages: Final = [{"role": "user", "content": "Please reply", "name": "requester"}]
result: Final = await runtime.run_program(
compiled,
{
"analyses": {
"urgency": {"summary": "soon"},
"intent": {"summary": "reply"},
"sentiment": {"summary": "neutral"},
},
"messages": messages,
},
deadline_monotonic=time.monotonic() + 10,
)
assert isinstance(result, MontySuccess), result
assert result.output == [
{
"role": "system",
"content": "Draft a customer-service reply using the original messages. "
"Treat these analyses as fallible guidance, not additional facts.\n"
"Urgency: soon\nIntent: reply\nSentiment: neutral",
},
*messages,
]
def _nested(depth: int) -> object:
return [_nested(depth - 1)] if depth else 1
@pytest.mark.asyncio
async def test_too_deep_input_and_output_are_rejected(runtime: MontyRuntime) -> None:
input_result: Final = await _run(runtime, _compile("def transform(value): return value"), _nested(66))
assert isinstance(input_result, MontyFailure), input_result
assert input_result.kind == MontyErrorKind.INPUT_TOO_LARGE
output_result: Final = await _run(
runtime, _compile("def transform(value):\n for i in range(66): value = [value]\n return value"), 1
)
assert isinstance(output_result, MontyFailure), output_result
assert output_result.kind == MontyErrorKind.OUTPUT_TOO_LARGE
@pytest.mark.asyncio
async def test_invalid_bound_input_is_rejected(runtime: MontyRuntime) -> None:
result: Final = await _run(
runtime, _compile("def transform(value): return value", argument={"type": "integer"}), "wrong"
)
assert isinstance(result, MontyFailure)
assert result.kind == MontyErrorKind.INPUT_INVALID
@pytest.mark.asyncio
@pytest.mark.parametrize("name", ["default", "pattern", "enum", "$ref", "$ref-like"])
async def test_keyword_looking_properties_validate_as_data(runtime: MontyRuntime, name: str) -> None:
schema: Final[JsonSchema] = {
"type": "object",
"properties": {name: {"type": "string"}},
"required": [name],
"additionalProperties": False,
}
compiled: Final = _compile("def transform(value): return value", schema, schema)
result: Final = await _run(runtime, compiled, {name: "data"})
assert isinstance(result, MontySuccess), result
assert result.output == {name: "data"}
invalid: Final = await _run(runtime, compiled, {name: 1})
assert isinstance(invalid, MontyFailure), invalid
assert invalid.kind == MontyErrorKind.INPUT_INVALID
@pytest.mark.asyncio
@pytest.mark.parametrize("output", [False, True])
async def test_bypassed_remote_schema_never_retrieves_from_host(runtime: MontyRuntime, output: bool) -> None:
requests: Final[asyncio.Queue[bytes]] = asyncio.Queue()
async def respond(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
requests.put_nowait(await reader.readuntil(b"\r\n\r\n"))
writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}")
await writer.drain()
writer.close()
await writer.wait_closed()
server: Final = await asyncio.start_server(respond, "127.0.0.1", 0)
async with server:
port: Final = server.sockets[0].getsockname()[1]
schema: Final[JsonSchema] = {"properties": {"default": {"$ref": f"http://127.0.0.1:{port}/schema"}}}
compiled: Final = _compile("def transform(value): return value")
bypassed: Final = (
replace(compiled, output_schema=schema)
if output
else replace(compiled, input_schema={"properties": {"value": schema}})
)
result: Final = await _run(runtime, bypassed, {"default": 1})
assert isinstance(result, MontyFailure), result
assert result.kind == (MontyErrorKind.OUTPUT_INVALID if output else MontyErrorKind.INPUT_INVALID)
assert requests.empty()
def _slow_schema(depth: int) -> JsonSchema:
definitions: Final[JsonSchema] = {
f"N{index}": {"anyOf": [{"$ref": f"#/$defs/N{index + 1}"}, {"$ref": f"#/$defs/N{index + 1}"}]}
for index in range(depth)
}
return {**definitions, f"N{depth}": {"type": "string"}}
@pytest.mark.asyncio
@pytest.mark.parametrize("output", [False, True])
async def test_expensive_allowed_validation_times_out_without_blocking_and_recovers(
runtime: MontyRuntime, output: bool
) -> None:
ticks: Final[asyncio.Queue[float]] = asyncio.Queue()
done: Final = asyncio.Event()
async def heartbeat() -> None:
while not done.is_set():
ticks.put_nowait(time.monotonic())
await asyncio.sleep(0.005)
schema: Final = _slow_schema(22)
ref: Final[JsonSchema] = {"$ref": "#/$defs/N0"}
compiled: Final = _compile(
"def transform(value): return value", ref if output else {}, {} if output else ref, schema
)
pulse: Final = asyncio.create_task(heartbeat())
await asyncio.sleep(0.01)
started: Final = time.monotonic()
try:
result: Final = await runtime.run_program(compiled, {"value": None}, deadline_monotonic=started + 0.15)
elapsed: Final = time.monotonic() - started
finally:
done.set()
await pulse
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.TIMEOUT
assert 0.1 <= elapsed < 0.5 # test-quality-ok: validation enforces the node deadline
observations: Final = tuple(ticks.get_nowait() for _ in range(ticks.qsize())) + (time.monotonic(),)
assert len(observations) > 5
assert max(later - earlier for earlier, later in zip(observations, observations[1:])) < 0.05
recovered: Final = await _run(runtime, _compile("def transform(value): return value"), "recovered")
assert isinstance(recovered, MontySuccess), recovered
assert recovered.output == "recovered"
def _validator_pids() -> frozenset[int]:
def is_validator(child: psutil.Process) -> bool:
try:
return any(argument.endswith("_validator_worker.py") for argument in child.cmdline())
except (psutil.NoSuchProcess, psutil.ZombieProcess):
return False
return frozenset(child.pid for child in psutil.Process().children() if is_validator(child))
@pytest.mark.asyncio
async def test_validator_environment_is_empty_and_close_reaps_processes(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LITEFLOW_VALIDATOR_CANARY", "fake-value-no-credential")
previous: Final = _validator_pids()
instance: Final = MontyRuntime(MontyLimits(max_concurrent_workers=1))
assert await instance.start() is None
try:
created: Final = _validator_pids() - previous
assert len(created) == 1
pid: Final = next(iter(created))
assert b"LITEFLOW_VALIDATOR_CANARY" not in Path(f"/proc/{pid}/environ").read_bytes()
assert Path(f"/proc/{pid}/environ").read_bytes() == b""
result: Final = await _run(instance, _compile("def transform(value): return value"), 1)
assert isinstance(result, MontySuccess), result
assert _validator_pids() - previous == created
finally:
await instance.close()
assert not (_validator_pids() - previous)
@pytest.mark.asyncio
@pytest.mark.parametrize("cancel", [False, True])
async def test_validator_crash_and_cancellation_release_slot_and_recover(runtime: MontyRuntime, cancel: bool) -> None:
pid: Final = next(iter(_validator_pids()))
compiled: Final = _compile(
"def transform(value): return value", argument={"$ref": "#/$defs/N0"}, definitions=_slow_schema(22)
)
task: Final = asyncio.create_task(_run(runtime, compiled, None))
await asyncio.sleep(0.05)
if cancel:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
else:
os.kill(pid, 9) # test-quality-ok: deliberately crash the owned validator to prove slot and subprocess recovery
result: Final = await task
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.WORKER_CRASHED
assert pid not in _validator_pids()
recovered: Final = await _run(runtime, _compile("def transform(value): return value"), "after failure")
assert isinstance(recovered, MontySuccess), recovered
assert recovered.output == "after failure"
@pytest.mark.asyncio
async def test_sandbox_denies_file_access_even_if_compiler_is_bypassed(runtime: MontyRuntime) -> None:
compiled: Final = _compile("def transform(value): return 42")
unfiltered: Final = replace(compiled, sandbox_source="open('/etc/passwd').read()")
result: Final = await _run(runtime, unfiltered, None)
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.RUNTIME_ERROR
@pytest.mark.asyncio
async def test_host_deadline_cancels_running_worker_and_releases_slot(runtime: MontyRuntime) -> None:
compiled: Final = _compile("def transform(value):\n while True: pass")
result: Final = await runtime.run_program(compiled, {"value": None}, deadline_monotonic=time.monotonic() + 0.05)
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.TIMEOUT
next_result: Final = await _run(runtime, _compile("def transform(value): return value"), 4)
assert isinstance(next_result, MontySuccess), next_result
assert next_result.output == 4
def _worker_pids() -> frozenset[int]:
children: Final = " ".join(
path.read_text() for path in Path(f"/proc/{os.getpid()}/task").glob("*/children")
).split()
return frozenset(int(pid) for pid in children if Path(f"/proc/{pid}/comm").read_text().strip() == "monty")
async def _active_worker(previous: frozenset[int]) -> int:
async with asyncio.timeout(5):
while True:
current: Final = _worker_pids() - previous
if current:
return next(iter(current))
await asyncio.sleep(0.01)
@pytest.mark.asyncio
async def test_cancellation_kills_worker_releases_slot_and_queue_is_bounded(runtime: MontyRuntime) -> None:
previous: Final = _worker_pids()
task: Final = asyncio.create_task(_run(runtime, _compile("def transform(value):\n while True: pass"), None))
pid: Final = await _active_worker(previous)
waiter: Final = asyncio.create_task(_run(runtime, _compile("def transform(value): return value"), 1))
await asyncio.sleep(0)
full: Final = await _run(runtime, _compile("def transform(value): return value"), 1)
assert isinstance(full, MontyFailure)
assert full.kind == MontyErrorKind.QUEUE_FULL
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
result: Final = await _run(runtime, _compile("def transform(value): return value"), 2)
assert isinstance(result, MontySuccess), result
assert result.output == 2
assert not Path(f"/proc/{pid}").exists()
@pytest.mark.asyncio
async def test_killed_worker_fails_and_next_run_succeeds(runtime: MontyRuntime) -> None:
previous: Final = _worker_pids()
task: Final = asyncio.create_task(_run(runtime, _compile("def transform(value):\n while True: pass"), None))
pid: Final = await _active_worker(previous)
await asyncio.to_thread(stop_processes, (psutil.Process(pid),), 0.1)
result: Final = await task
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.WORKER_CRASHED
next_result: Final = await _run(runtime, _compile("def transform(value): return value"), 3)
assert isinstance(next_result, MontySuccess), next_result
assert next_result.output == 3
@pytest.mark.asyncio
async def test_fresh_worker_has_no_parent_canary_and_has_address_space_limit(
runtime: MontyRuntime, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITEFLOW_ENV_CANARY", "fake-value-no-credential")
previous: Final = _worker_pids()
task: Final = asyncio.create_task(_run(runtime, _compile("def transform(value):\n while True: pass"), None))
pid: Final = await _active_worker(previous)
assert b"LITEFLOW_ENV_CANARY" not in Path(f"/proc/{pid}/environ").read_bytes()
async with asyncio.timeout(5):
while str(runtime.limits.worker_address_space_bytes) not in Path(f"/proc/{pid}/limits").read_text():
await asyncio.sleep(0.01)
assert str(runtime.limits.worker_address_space_bytes) in Path(f"/proc/{pid}/limits").read_text()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
second: Final = asyncio.create_task(_run(runtime, _compile("def transform(value):\n while True: pass"), None))
next_pid: Final = await _active_worker(previous | {pid})
assert next_pid != pid
second.cancel()
with pytest.raises(asyncio.CancelledError):
await second

View file

View file

@ -0,0 +1,399 @@
import ast
import hashlib
from functools import reduce
from typing import Final
import pytest
from litellm.proxy.workflows.monty.compiler import _SchemaWork, _schemas_error, compile_program, escape_diagnostics
from litellm.proxy.workflows.monty.models import (
CompiledProgram,
JsonSchema,
MontyErrorKind,
MontyFailure,
MontyLimits,
PythonProgram,
)
def _program(source: str, *, output: JsonSchema | None = None) -> PythonProgram:
return PythonProgram(
contract_version="1",
source=source,
function_name="transform",
parameter_names=("value",),
input_schema={
"type": "object",
"properties": {"value": {}},
"required": ["value"],
"additionalProperties": False,
},
output_schema=output if output is not None else {},
source_sha256=hashlib.sha256(source.encode()).hexdigest(),
)
@pytest.mark.parametrize(
"source",
[
"import os\ndef transform(value): return value",
"from typing import TypedDict\ndef transform(value): return value",
"x=1\ndef transform(value): return value",
"print('side effect')\ndef transform(value): return value",
"async def transform(value): return value",
"class X: pass\ndef transform(value): return value",
"@print\ndef transform(value): return value",
"def transform(value):\n def closure(): return value\n return closure()",
"def transform(value): return (lambda: value)()",
"def transform(value):\n global x\n return value",
"def transform(value):\n nonlocal x\n return value",
"def transform(value): return value.__class__",
"def transform(value): return __name__",
"# /// script\ndef transform(value): return value",
"def transform(other): return other",
"def transform(value=1): return value",
"def transform(*value): return value",
"def transform(value, **kwargs): return value",
"def transform(*, value): return value",
"def transform(value, /): return value",
"def transform(value):\n import json\n return value",
"def transform(value): return value\ndef isinstance(x,y): return True",
],
)
def test_rejects_unsupported_source_without_running_it(source: str) -> None:
program: Final = _program(source)
result: Final = compile_program(program, MontyLimits())
assert isinstance(result, MontyFailure)
assert result.kind == MontyErrorKind.INVALID_PROGRAM
assert "line" in result.message
@pytest.mark.parametrize(
"builtin",
[
"eval",
"exec",
"compile",
"open",
"__import__",
"globals",
"locals",
"vars",
"getattr",
"setattr",
"delattr",
"breakpoint",
"input",
"help",
],
)
def test_rejects_dangerous_builtin_references(builtin: str) -> None:
result: Final = compile_program(_program(f"def transform(value): return {builtin}"), MontyLimits())
assert isinstance(result, MontyFailure)
assert result.kind == MontyErrorKind.INVALID_PROGRAM
@pytest.mark.parametrize(
"schema",
[
{"$ref": "https://example.invalid/schema"},
{"$ref": "file:///etc/passwd"},
{"$id": "https://example.invalid/schema"},
{"$dynamicRef": "#/x"},
{"$schema": "https://example.invalid/schema"},
{"$ref": "#/$defs/Missing"},
{"type": "not-a-json-type"},
{"$defs": {"Bad": {"type": "object", "x-liteflow-record": True, "properties": {}}}},
{
"$defs": {
"Bad-Name": {
"type": "object",
"x-liteflow-record": True,
"properties": {},
"additionalProperties": False,
}
}
},
],
)
def test_rejects_unsupported_schema_without_remote_retrieval(schema: JsonSchema) -> None:
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
assert isinstance(result, MontyFailure)
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
def test_hash_covers_exact_source_bytes() -> None:
program: Final = _program("def transform(value): return value")
result: Final = compile_program(program.model_copy(update={"source": program.source + "\n"}), MontyLimits())
assert isinstance(result, MontyFailure)
assert result.kind == MontyErrorKind.INVALID_PROGRAM
assert "source_sha256" in result.message
def test_annotations_are_not_evaluated() -> None:
result: Final = compile_program(
_program("def transform(value: Unknown[Foo]) -> Missing: return value"), MontyLimits()
)
assert isinstance(result, CompiledProgram)
def test_source_size_is_bounded() -> None:
result: Final = compile_program(_program("def transform(value): return value"), MontyLimits(max_source_bytes=8))
assert isinstance(result, MontyFailure)
assert result.kind == MontyErrorKind.INVALID_PROGRAM
def test_diagnostics_escape_terminal_controls_but_preserve_layout() -> None:
assert escape_diagnostics("a\x1b[31m\x00\x7f\x9b\n\t") == "a\\x1b[31m\\x00\\x7f\\x9b\n\t"
def _array_schema(child: JsonSchema, index: int) -> JsonSchema:
return {"type": "array", "items": child}
@pytest.mark.parametrize("depth,accepted", [(4, True), (70, False)])
def test_schema_nesting_is_bounded(depth: int, accepted: bool) -> None:
schema: Final = reduce(_array_schema, range(depth), {})
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
if accepted:
assert isinstance(result, CompiledProgram), result
return
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
assert "depth limit" in result.message
@pytest.mark.parametrize("depth,accepted", [(4, True), (70, False)])
def test_schema_reference_expansion_is_bounded(depth: int, accepted: bool) -> None:
schema: Final[JsonSchema] = {
"$defs": {
f"N{index}": {"$ref": f"#/$defs/N{index + 1}"} if index < depth else {"type": "integer"}
for index in range(depth + 1)
},
"$ref": "#/$defs/N0",
}
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
if accepted:
assert isinstance(result, CompiledProgram), result
return
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
assert "depth limit" in result.message
def test_schema_reference_cycle_is_rejected() -> None:
schema: Final[JsonSchema] = {"$defs": {"X": {"$ref": "#/$defs/X"}}, "$ref": "#/$defs/X"}
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
assert "recursive schema" in result.message
_RECORD: Final[JsonSchema] = {
"type": "object",
"x-liteflow-record": True,
"properties": {"amount": {"type": "integer"}, "label": {"type": ["string", "null"]}},
"required": ["amount"],
"additionalProperties": False,
}
_RECORD_REF: Final[JsonSchema] = {"$ref": "#/$defs/Record"}
@pytest.mark.parametrize(
"argument",
[
_RECORD_REF,
{"type": "array", "items": _RECORD_REF},
{"anyOf": [_RECORD_REF, {"type": "null"}]},
{"type": "object", "properties": {"payload": _RECORD_REF}, "additionalProperties": True},
{"type": "object", "properties": {}, "additionalProperties": _RECORD_REF},
{"$ref": "#/$defs/Envelope"},
],
)
def test_registration_accepts_records_in_supported_input_shapes(argument: JsonSchema) -> None:
definitions: Final[JsonSchema] = {
"Record": _RECORD,
"Empty": {**_RECORD, "properties": {}, "required": []},
"Envelope": {**_RECORD, "properties": {"child": _RECORD_REF}, "required": ["child"]},
}
program: Final = _program("def transform(value): return value", output={"$defs": definitions})
typed: Final = program.model_copy(
update={
"input_schema": {
**program.input_schema,
"properties": {"value": argument},
"$defs": definitions,
}
}
)
result: Final = compile_program(typed, MontyLimits())
assert isinstance(result, CompiledProgram), result
@pytest.mark.parametrize(
"schema",
[
_RECORD,
{"$defs": {"Record": {**_RECORD, "type": "string"}}},
{"$defs": {"Record": {**_RECORD, "properties": {"bad-field": {}}}}},
{"$defs": {"str": _RECORD}},
{"$defs": {"transform": _RECORD}},
{"$defs": {"Record": _RECORD}, "oneOf": [_RECORD_REF, {"type": "null"}]},
{"$defs": {"Record": _RECORD}, "anyOf": [_RECORD_REF, {"type": "string"}]},
{"$defs": {"Record": _RECORD}, "anyOf": [_RECORD_REF, {"type": "null"}, {"type": "string"}]},
{"$defs": {"Record": _RECORD}, "allOf": [_RECORD_REF]},
],
)
def test_registration_rejects_ambiguous_or_unconstructible_record_shapes(schema: JsonSchema) -> None:
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
@pytest.mark.parametrize(
"changes",
[
{"type": "array"},
{"properties": {"extra": {}}},
{"required": []},
{"required": ["value", "extra"]},
{"additionalProperties": True},
],
)
def test_input_schema_must_require_exactly_the_entry_parameters(changes: JsonSchema) -> None:
program: Final = _program("def transform(value): return value")
invalid: Final = program.model_copy(update={"input_schema": {**program.input_schema, **changes}})
result: Final = compile_program(invalid, MontyLimits())
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
def test_registration_rejects_conflicting_record_definitions() -> None:
program: Final = _program("def transform(value): return value", output={"$defs": {"Record": _RECORD}})
conflicting: Final = program.model_copy(
update={"input_schema": {**program.input_schema, "$defs": {"Record": {**_RECORD, "required": []}}}}
)
result: Final = compile_program(conflicting, MontyLimits())
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
assert "conflicting" in result.message
@pytest.mark.parametrize(
"source",
["def transform(value):\n", "def transform(value): return value\ndef transform(value): return 1"],
)
def test_registration_rejects_syntax_errors_and_duplicate_entry_names(source: str) -> None:
result: Final = compile_program(_program(source), MontyLimits())
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.INVALID_PROGRAM
def test_registration_accepts_local_json_pointer_escaping_and_annotations_import() -> None:
schema: Final[JsonSchema] = {"$defs": {"a/b~c": {"type": "integer"}}, "$ref": "#/$defs/a~1b~0c"}
source: Final = "from __future__ import annotations\ndef transform(value: Missing) -> Unknown: return value"
result: Final = compile_program(_program(source, output=schema), MontyLimits())
assert isinstance(result, CompiledProgram), result
@pytest.mark.parametrize("name", ["default", "pattern", "enum", "$ref", "$ref-like"])
def test_keyword_looking_property_names_are_data(name: str) -> None:
schema: Final[JsonSchema] = {"type": "object", "properties": {name: {"type": "string"}}}
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
assert isinstance(result, CompiledProgram), result
@pytest.mark.parametrize(
"schema",
[
{"properties": {"default": {"$ref": "https://example.invalid/schema"}}},
{"$defs": {"default": {"items": {"$ref": "https://example.invalid/schema"}}}},
{"anyOf": [{"properties": {"enum": {"$ref": "https://example.invalid/schema"}}}]},
{"default": {"$ref": "https://example.invalid/schema"}, "$ref": "#/default"},
],
)
def test_remote_references_under_keyword_looking_names_are_rejected(schema: JsonSchema) -> None:
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
assert "local" in result.message
@pytest.mark.parametrize(
"keyword",
[
"pattern",
"patternProperties",
"uniqueItems",
"$id",
"$anchor",
"$dynamicRef",
"$dynamicAnchor",
"$schema",
"if",
"then",
"else",
"dependentRequired",
"dependentSchemas",
"contains",
"minContains",
"maxContains",
"propertyNames",
"unevaluatedProperties",
"unevaluatedItems",
"discriminator",
"unknown",
],
)
def test_unsafe_keywords_are_explicitly_refused(keyword: str) -> None:
result: Final = compile_program(_program("def transform(value): return value", output={keyword: {}}), MontyLimits())
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
assert keyword in result.message
@pytest.mark.parametrize("keyword", ["const", "enum", "default", "examples"])
def test_annotation_and_data_values_are_not_interpreted_as_schemas(keyword: str) -> None:
payload: Final[JsonSchema] = {"$ref": "https://example.invalid/data", "pattern": "inert"}
schema: Final[JsonSchema] = {keyword: [payload] if keyword in {"enum", "examples"} else payload}
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
assert isinstance(result, CompiledProgram), result
@pytest.mark.parametrize("depth", [8, 12, 14])
def test_repeated_reference_analysis_has_linear_work(depth: int) -> None:
definitions: Final[JsonSchema] = {
f"N{index}": {"anyOf": [{"$ref": f"#/$defs/N{index + 1}"}, {"$ref": f"#/$defs/N{index + 1}"}]}
for index in range(depth)
}
schema: Final[JsonSchema] = {"$defs": {**definitions, f"N{depth}": {}}, "$ref": "#/$defs/N0"}
program: Final = _program("def transform(value): return value", output=schema)
work: Final = _SchemaWork()
assert _schemas_error(program, ast.parse(program.source), work) is None
assert work.units < 100 * (depth + 1)
result: Final = compile_program(program, MontyLimits())
assert isinstance(result, CompiledProgram), result
def test_expanded_input_builder_fails_closed_at_shared_work_budget() -> None:
definitions: Final[JsonSchema] = {
f"N{index}": {
"type": "object",
"properties": {"left": {"$ref": f"#/$defs/N{index + 1}"}, "right": {"$ref": f"#/$defs/N{index + 1}"}},
}
for index in range(14)
}
program: Final = _program("def transform(value): return value")
expanded: Final = program.model_copy(
update={
"input_schema": {
**program.input_schema,
"$defs": {**definitions, "N14": {"type": "integer"}},
"properties": {"value": {"$ref": "#/$defs/N0"}},
}
}
)
result: Final = compile_program(expanded, MontyLimits())
assert isinstance(result, MontyFailure), result
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
assert result.message == "schema expansion budget exceeded"

View file

@ -0,0 +1,55 @@
from typing import Final
import pytest
from pydantic import ValidationError
from litellm.proxy.workflows.monty.models import MontyLimits, PythonProgram
def _artifact() -> PythonProgram:
return PythonProgram(
contract_version="1",
source="def transform(value): return value",
function_name="transform",
parameter_names=("value",),
input_schema={},
output_schema={},
source_sha256="a" * 64,
)
def test_program_rejects_unknown_fields() -> None:
with pytest.raises(ValidationError, match="extra_forbidden"):
PythonProgram.model_validate({**_artifact().model_dump(), "credentials": "not-allowed"})
def test_limits_reject_unknown_fields() -> None:
with pytest.raises(ValidationError, match="extra_forbidden"):
MontyLimits.model_validate({"mount": "/"})
@pytest.mark.parametrize("field", tuple(MontyLimits.model_fields))
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf"), -1, 0, 10**20])
def test_every_limit_is_positive_finite_and_bounded(field: str, value: float | int) -> None:
with pytest.raises(ValidationError):
MontyLimits.model_validate({field: value})
@pytest.mark.parametrize(
"field,value",
[
("function_name", "not valid"),
("function_name", "class"),
("function_name", "x" * 129),
("parameter_names", ("bad-name",)),
("parameter_names", ("x" * 129,)),
("parameter_names", tuple(f"p{i}" for i in range(33))),
("source_sha256", "A" * 64),
("source_sha256", "a" * 63),
("source_sha256", "g" * 64),
],
)
def test_artifact_names_count_and_digest_are_bounded(field: str, value: object) -> None:
payload: Final = {**_artifact().model_dump(), field: value}
with pytest.raises(ValidationError):
PythonProgram.model_validate(payload)

145
uv.lock generated
View file

@ -5113,6 +5113,7 @@ proxy = [
{ name = "polars" },
{ name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
{ name = "pydantic", version = "2.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
{ name = "pydantic-monty" },
{ name = "pyjwt" },
{ name = "pynacl" },
{ name = "pyroscope-io", marker = "sys_platform != 'win32'" },
@ -5287,6 +5288,7 @@ proxy-dev = [
{ name = "opentelemetry-sdk" },
{ name = "prisma" },
{ name = "prometheus-client" },
{ name = "pydantic-monty" },
{ name = "semver" },
{ name = "sentry-sdk" },
]
@ -5377,6 +5379,7 @@ requires-dist = [
{ name = "pydantic", marker = "python_full_version >= '3.15'", specifier = ">=2.14.0,<3.0.0" },
{ name = "pydantic", marker = "extra == 'mcp'", specifier = ">=2.12.0,<3" },
{ name = "pydantic", marker = "extra == 'proxy'", specifier = ">=2.12.0,<3" },
{ name = "pydantic-monty", marker = "extra == 'proxy'", specifier = "==1.4.0" },
{ name = "pydantic-settings", specifier = ">=2.14.1,<3.0" },
{ name = "pyjwt", marker = "extra == 'proxy'", specifier = ">=2.13.0,<3.0" },
{ name = "pynacl", marker = "extra == 'proxy'", specifier = ">=1.6.2,<2.0" },
@ -5523,6 +5526,7 @@ proxy-dev = [
{ name = "opentelemetry-sdk", specifier = "==1.45.1" },
{ name = "prisma", specifier = "==0.11.0" },
{ name = "prometheus-client", specifier = "==0.20.0" },
{ name = "pydantic-monty", specifier = "==1.4.0" },
{ name = "semver", specifier = ">=3.0.4,<4" },
{ name = "sentry-sdk", specifier = "==2.21.0" },
]
@ -8828,6 +8832,147 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/f3/d15bc0b1fb0c4f1e07b3326ffb7489f7ae70da2b56a0884d22641b0eb47c/pydantic_core-2.50.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f476456ac2bb0d937f75191494a09c83a30765fea4f70f3b404942fe25f6cdf", size = 2336787, upload-time = "2026-10-11T18:35:42.558Z" },
]
[[package]]
name = "pydantic-monty"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic-monty-client" },
{ name = "pydantic-monty-runtime" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/7c/58467370dd9f2db100ee2627791668f6f21a9fa150ccff9e60efc588ab8f/pydantic_monty-1.4.0.tar.gz", hash = "sha256:db1f1c779f509f7c547e580620a63ab8968cf167fd6a45a4f60b6625187a9848", size = 8025, upload-time = "2026-10-26T12:31:49.864Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/5a/5cb59f15ca5b89df8ea810397523877d0ffba33419a8975f5bde8795b262/pydantic_monty-1.4.0-py3-none-any.whl", hash = "sha256:e398217fdb701e4daaeeb9fd65ad9319627cb114334bc00d6cf2444845c43bca", size = 7552, upload-time = "2026-10-26T12:28:25.388Z" },
]
[[package]]
name = "pydantic-monty-client"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/78/0f94fe1b8b45ba5e9c8bc622117f949820a71f7e96a67a7b691fdda96e3d/pydantic_monty_client-1.4.0.tar.gz", hash = "sha256:2252ca45e3cfd241a63950fde184b2ff06ddce5b3e1c260282c0733d70590acd", size = 2916981, upload-time = "2026-10-26T12:31:51.132Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/9f/c16b213349757ee032946bb29b59a50d9593820765a347cdeec66b2b4fff/pydantic_monty_client-1.4.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:6fc2b38d871932b43cf08d6ccd9cc38ca6f9fe386845658ea35f4f0de99e6c4a", size = 4288592, upload-time = "2026-10-26T12:28:26.929Z" },
{ url = "https://files.pythonhosted.org/packages/38/32/97f6b6bd430e32a69f3398e3528ac5c4140d79303b18a0d3d59ede7a02c9/pydantic_monty_client-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d4597ffa0664a0f2aac77d95f8d2c8786444b1d6aa7c5411e5538dde2d37ff96", size = 4060552, upload-time = "2026-10-26T12:28:28.661Z" },
{ url = "https://files.pythonhosted.org/packages/34/b1/327f06e728f8d9e0369ae6335775387fd2ee39eb0d6d528878590d316389/pydantic_monty_client-1.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:141422a3af8536a139f7e099e9f7ac3f78307d2381564ec91239b3a91bd13bf7", size = 4122496, upload-time = "2026-10-26T12:28:30.273Z" },
{ url = "https://files.pythonhosted.org/packages/17/a2/77563d71661923383addaae926f8bdb5799e54fe91434b1df41db814d544/pydantic_monty_client-1.4.0-cp310-cp310-manylinux_2_28_armv7l.whl", hash = "sha256:32eaf5482170020b1bf85576c58d297a99dfdb126aecc451b266a6cf12b00bf2", size = 3807830, upload-time = "2026-10-26T12:28:31.926Z" },
{ url = "https://files.pythonhosted.org/packages/9b/4a/9d505fea5afebfcfcfee49374dcf618e0fc4768bfbd66c545e9e429663ff/pydantic_monty_client-1.4.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:0ec3c0205f02bc7d327fc8c53e985c68df5991607c8e18ec9b2d3562519a08ff", size = 4049655, upload-time = "2026-10-26T12:28:33.607Z" },
{ url = "https://files.pythonhosted.org/packages/0b/2a/f0b3300858e34a66d5a0270d7c2ef3d5c4bbca649b0ffcef548e9f122722/pydantic_monty_client-1.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:33b9aa71bd8bf5cd0f65ab9dbf8241684f0cf3d7d70c99eb00bc45a6608b43ae", size = 4260607, upload-time = "2026-10-26T12:28:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/71/d0/7bab7113a230bb21e28ccec28d2aa8cfd17426528c6a541baa01e8f18d90/pydantic_monty_client-1.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a7e4a7c3d04651cf6c2f6503fec7694b979c02a1a286546949500d750f0210a", size = 4154742, upload-time = "2026-10-26T12:28:37.05Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d1/506e3450bddedc44f80ce40fda1840f65da26b15de4ff8f6c123cb3c8897/pydantic_monty_client-1.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:2204520a4f4c4360527aa957e9b79f0295f14dfd30ea1bc0b7db373e7d8958e5", size = 4411287, upload-time = "2026-10-26T12:28:38.494Z" },
{ url = "https://files.pythonhosted.org/packages/a0/91/59cc51af399c80490231207c54ca2557f36fd48024294a707c28d09392e9/pydantic_monty_client-1.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:309b2fa11943de2711ff9e453a2327cfd265192ac6b5ecad19fafde31671cdb5", size = 4295152, upload-time = "2026-10-26T12:28:39.921Z" },
{ url = "https://files.pythonhosted.org/packages/5a/17/ad54eab3c573c7e186ff270eef3389717c1c4d239b51f8901901daf5be6f/pydantic_monty_client-1.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d45d0351d563dc652f8963339a098c0dd29b026df10a4dc92fdcae1c04f420af", size = 4605594, upload-time = "2026-10-26T12:28:41.769Z" },
{ url = "https://files.pythonhosted.org/packages/50/83/e9207c92d37f445b745cadcf0a5a1b52c3fdaac94b4aa9b04347093666dc/pydantic_monty_client-1.4.0-cp310-cp310-win32.whl", hash = "sha256:959ac1ec194f32d17b6e78fff5b7c7b31815e68b551dd17d0c6ab9b007c020e9", size = 3686561, upload-time = "2026-10-26T12:28:43.27Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e2/28ded8f46e31c635c0fea322fce526ec05df71c5f642373c0595ab96a1f4/pydantic_monty_client-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:959e5e03edd2f6ab719a306cd36f86e5cfb1bcd398f346ee439876dbce35108a", size = 4360024, upload-time = "2026-10-26T12:28:45.302Z" },
{ url = "https://files.pythonhosted.org/packages/91/7b/2798ebabdd90c1258274e4f85541130d31e9d543d330eb3ee98b98eaee2a/pydantic_monty_client-1.4.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e15c2a6f2833806ddb1f4334aa8272cdc9ab0a1c2301824af6354b88e2fb17c", size = 4287529, upload-time = "2026-10-26T12:28:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/62/6c/f1618692da03841fb11a73731b52b6d11e0b861d47034059c877371c0950/pydantic_monty_client-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:485981ad29da00f295ad994299db809bb60aeb3b664beff7b6f5c31116c78b59", size = 4060688, upload-time = "2026-10-26T12:28:48.913Z" },
{ url = "https://files.pythonhosted.org/packages/82/f3/7c45f7f38b75d94a3f7164d79cb32f1bcd88ee657ed5646f4ea4c6bc2503/pydantic_monty_client-1.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:063534e65ae971099f2e64c674626d871aa3efcf6df983db5ba520d9085a60a6", size = 4121979, upload-time = "2026-10-26T12:28:50.783Z" },
{ url = "https://files.pythonhosted.org/packages/4f/94/e47425838420e172664868c93e99730cfc3ada642eb467be15deebbde4cc/pydantic_monty_client-1.4.0-cp311-cp311-manylinux_2_28_armv7l.whl", hash = "sha256:fbd9ae34c2d68d0b14774468f87c43369041631b4de469e336ada7a34a57a30f", size = 3806350, upload-time = "2026-10-26T12:28:52.244Z" },
{ url = "https://files.pythonhosted.org/packages/6e/9f/4693140e8e27f2afa1c6d718f2d45ec17f201a57189f3353ed4dcad80577/pydantic_monty_client-1.4.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:814f551fe454c5db082192b53d75ec4f71c1b790ea944d68763528aba714e182", size = 4048270, upload-time = "2026-10-26T12:28:53.976Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c6/69c7747f62b6a5a25d450563c630190cec254ef43e4dab0446329733bcb2/pydantic_monty_client-1.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:69d7a87c60a8a2eea5b488f6c373b49f4c11a61917fbcd45c1247f7daf0a3a45", size = 4259483, upload-time = "2026-10-26T12:28:55.879Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9c/b901f54c53ad9ba2cfb2a0319fb59211304b51512746b4c0be1516f3ca6a/pydantic_monty_client-1.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:94cf89b243dc9dad970f36072849e7607fc4e7573d0b190be4613bd6167d1b9a", size = 4153767, upload-time = "2026-10-26T12:28:57.644Z" },
{ url = "https://files.pythonhosted.org/packages/f8/48/e342373a0164fe5ed71015707de82f66761e107cfe54bb77c2a2f67854fd/pydantic_monty_client-1.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d5676ea4b2fead463c427f0a4536f59156feb978c56bd0862aef5ba24c00aff8", size = 4410295, upload-time = "2026-10-26T12:28:59.915Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b0/d85b45cea1a542251ef229f21b31f5d9368cfc3f24ad7c88b7e7efbfc95a/pydantic_monty_client-1.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:12e35274ddac034f8c4772e1e0931a15d834ba8db84d347372e2d09f349f2168", size = 4293927, upload-time = "2026-10-26T12:29:01.998Z" },
{ url = "https://files.pythonhosted.org/packages/57/5d/4fbfae9af7c350b3672f2684253baf422299cea6e282d69a4648e127e26c/pydantic_monty_client-1.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d28aa3d51cb522861cf49f7b21e3405afa256d0938606c330e72e2089a4a158e", size = 4604347, upload-time = "2026-10-26T12:29:03.604Z" },
{ url = "https://files.pythonhosted.org/packages/b5/de/a167924d35a052a74f94e14d9793cb9c0d3cb2fa159b0b2e48c7c1f4e180/pydantic_monty_client-1.4.0-cp311-cp311-win32.whl", hash = "sha256:ce66f43bea399ae8454c603664ff2bdb9bb3a58034a4e2855fdec85d412c4a08", size = 3685412, upload-time = "2026-10-26T12:29:05.397Z" },
{ url = "https://files.pythonhosted.org/packages/cd/7f/24317b4e747aca7aa85dd62d5540f976fdf5a7eaaa40f351e7586c1812b8/pydantic_monty_client-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:25440b0b3c5b7eebf010075729db1b4778cf90e9cdebd988a1fd5714df73f85b", size = 4359255, upload-time = "2026-10-26T12:29:06.95Z" },
{ url = "https://files.pythonhosted.org/packages/64/9f/6605612467001a29734947e3e66eb2f9c03172e524ad89a72ede2c00e2c5/pydantic_monty_client-1.4.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c771ded8705f24fdac9ea119547ffe3cd7126ac4d3f7a1514adf7f3f9961f4c", size = 4317732, upload-time = "2026-10-26T12:29:08.5Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a0/bf17349667f3a3f76054fac14702840bd745a86c4de86cebaa4f50db742b/pydantic_monty_client-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0f0677e9a3be73159c772c4bc55292150356b5080cc4623196dd57407ddc5504", size = 4046984, upload-time = "2026-10-26T12:29:09.991Z" },
{ url = "https://files.pythonhosted.org/packages/b0/2e/5dca50a7086efb25fd7850595bd491c76692bc8c83e1331a0ca5dfd71331/pydantic_monty_client-1.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:287614a13d60c590475a39708171b5a037a6dbb6868e0125871cce365009846a", size = 4121512, upload-time = "2026-10-26T12:29:11.416Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c4/b5dd4714b9cb204f1a992e6244066dbb1e880eae74303e526a77f2a1536f/pydantic_monty_client-1.4.0-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:8bfe5c08eea64423174712f641189dcc5e9fc2caad18c8b243af041bec8402a0", size = 3805501, upload-time = "2026-10-26T12:29:12.912Z" },
{ url = "https://files.pythonhosted.org/packages/12/1a/a54b7346c8b045318d65eebf97d09817dc3d04cf89dd2e678c6f3d2ddc6a/pydantic_monty_client-1.4.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:7471989402d267e181d7d5a528cc48a4674d3815fc56a67aeeb8caaeef324477", size = 4045097, upload-time = "2026-10-26T12:29:14.75Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a4/94d8e46b0ed94c3f1f39c44269d7241363ca071d46351e4f0088064019cc/pydantic_monty_client-1.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:d6f50a54f2a315897c047dc503881282d02bc601a5da04aa464af071ddc97fa0", size = 4257816, upload-time = "2026-10-26T12:29:16.563Z" },
{ url = "https://files.pythonhosted.org/packages/cd/23/80b53e023b05149ccd318f0873c4f00f1cddf53ad7bf4c72cda439ad3982/pydantic_monty_client-1.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:35217394abf732e2df1033f7a5c3b28eb2b78ac4ce468886f88214d1d342e6ff", size = 4159259, upload-time = "2026-10-26T12:29:18.121Z" },
{ url = "https://files.pythonhosted.org/packages/23/3e/827db7c549b4b33cf3d3c66ec3e53920ec40ce86228b85ee3687053150e9/pydantic_monty_client-1.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5f3f32a9cc72aa831ab4c951b4c6e720a1c81cca0db8d8956b70f5c242381ba8", size = 4410442, upload-time = "2026-10-26T12:29:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/b1/94/ea72c9c9f05e859c1275191567274d63b0cfd84a40fd700270b91765ed04/pydantic_monty_client-1.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4c4e54a0f670fe37fc8a6c065286c83061e5269bfb5bb3a9a0ee4f0369868e19", size = 4288907, upload-time = "2026-10-26T12:29:23.365Z" },
{ url = "https://files.pythonhosted.org/packages/08/86/3eec37a86a95418c9dfd36a34216b66aa0da08c94ff908f6745bdca49a0b/pydantic_monty_client-1.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1edfff6112e2757e2e1760d99a6b7d400f6a4f2fe7f77053e030f21e5d807255", size = 4605169, upload-time = "2026-10-26T12:29:25.005Z" },
{ url = "https://files.pythonhosted.org/packages/ac/39/c49055ba96c72eb365faa8abd88f0467f8c750c51a7fa01a0c19622973c5/pydantic_monty_client-1.4.0-cp312-cp312-win32.whl", hash = "sha256:f7ad83cb3ac4939c79ecc78b7cee6b0db8c5f69be5ca3396dc36d6311b3c2261", size = 3679130, upload-time = "2026-10-26T12:29:27.175Z" },
{ url = "https://files.pythonhosted.org/packages/30/bb/9bd7cb6d9975e10a62fb77836137fbc63111998639920c31a43d06e43b8d/pydantic_monty_client-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:be6f88686d3e93f73e006c7d88fee42350986afc7d307a08f4b0cd3f54bbcf49", size = 4346625, upload-time = "2026-10-26T12:29:28.716Z" },
{ url = "https://files.pythonhosted.org/packages/0e/90/7fb99bed2696f847f9a6cda709ae24f3e8eb188d3e9b869a9982a9a8ae76/pydantic_monty_client-1.4.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f3104fab8df6995769c9b241f634e99d46782270e21d5b8b197bbe5874866b2f", size = 4324480, upload-time = "2026-10-26T12:29:31.232Z" },
{ url = "https://files.pythonhosted.org/packages/2f/03/47bf82469d8e7d451b1c5e5bd2fae427d4768346d6ea271d0a0f8fd4bb14/pydantic_monty_client-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c900bebb52812a58055a7cb00f8f4e678688aab2e66f8262e9d0733f1ff5874a", size = 4052142, upload-time = "2026-10-26T12:29:32.999Z" },
{ url = "https://files.pythonhosted.org/packages/2b/06/ac16fc6aded0eb73e18a27e4f7835f1810e6f9fa1e8f01fd2f8bcb81670f/pydantic_monty_client-1.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3dc3e1d518f5732cdf254bdea8f56f54408ac3a5d84818ec992286f6859fd477", size = 4123662, upload-time = "2026-10-26T12:29:38.367Z" },
{ url = "https://files.pythonhosted.org/packages/39/8e/1ab5ef0e71fea32e29305fc7b2890107858008e8e5f6cc1e5b9a169092fb/pydantic_monty_client-1.4.0-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:6ad41d4809f51523698fb0fdf8dda2506abc120f7e4d409f75205c0bea40f158", size = 3806072, upload-time = "2026-10-26T12:29:39.849Z" },
{ url = "https://files.pythonhosted.org/packages/49/86/477ef5c02ec571b73853d20bf6927f81e04bd975525b168fcbdcdc3a98c9/pydantic_monty_client-1.4.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:aa0c3ecd210818c53fc9965ffd863ec7fa725373fda73e70627311fbbc8c12fc", size = 4044974, upload-time = "2026-10-26T12:29:41.35Z" },
{ url = "https://files.pythonhosted.org/packages/4b/92/03aee4671043f25ab2443979dd000486a3f6642ee860413c82febec340ca/pydantic_monty_client-1.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:879b5c2351527d0d4ce7de73b0330bb934184fd62fe04104166849c233827e19", size = 4261162, upload-time = "2026-10-26T12:29:43.154Z" },
{ url = "https://files.pythonhosted.org/packages/b2/b3/ec5ab86e16987df0dd31c0dd3df2ddb0efa89367bc2106e1818ef5703715/pydantic_monty_client-1.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:15e9949499463fdf707953689a210b7c8f7af216877db7474eadd35f25418d52", size = 4161701, upload-time = "2026-10-26T12:29:45.275Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/c6e3c5ce48dec3faae4a70690feeeaf4cd989b582f5aec1b305ae0f0fa13/pydantic_monty_client-1.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:15bd85bc75f1e6fafae2fc7f83f88348795d4376f25e7ede8d23b1cc504005a9", size = 4415744, upload-time = "2026-10-26T12:29:46.821Z" },
{ url = "https://files.pythonhosted.org/packages/e8/26/7db5c0400544bd63be3f6c7b4c6855ae4232610be7a81016f9c0cc2e1ba5/pydantic_monty_client-1.4.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:4079c47f62fce1655e1c3004f630676e18cf9fa2b652adff835fad03610b3762", size = 4292858, upload-time = "2026-10-26T12:29:48.405Z" },
{ url = "https://files.pythonhosted.org/packages/50/43/e560d6e2ae6f355e2306a8a1c7149ef49271ed4e349d1ecfcd26aba5a418/pydantic_monty_client-1.4.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:fc5b9e2d2ec0e2e6f38497803c61dff5c800e9b8f37f9b127e6ae339b7aa4f20", size = 4609688, upload-time = "2026-10-26T12:29:49.883Z" },
{ url = "https://files.pythonhosted.org/packages/12/7c/6af90b241f4adada9bfa7c5170b13ec6cfbb0aad319aea4fd6688080cfa1/pydantic_monty_client-1.4.0-cp313-cp313-win32.whl", hash = "sha256:272eb19ae41423448da0673a135bc0ecf3edf4f9d7860efb357b555efcab9563", size = 3679388, upload-time = "2026-10-26T12:29:51.321Z" },
{ url = "https://files.pythonhosted.org/packages/56/46/ffa8a123d9f6aa95984216faa90da223f3657f741f93573dee278e687ea8/pydantic_monty_client-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce9dbbceaa891fbef9edb2428839e636784c1fca10c4b4c72179ad8ae5f85338", size = 4350595, upload-time = "2026-10-26T12:29:52.9Z" },
{ url = "https://files.pythonhosted.org/packages/6f/4e/23788c86158a3ba16246716c6f0d0e331b0271a98ca2ee35a28772bcb09a/pydantic_monty_client-1.4.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d68b922161f7ed637addd58ee9e25082406009832e114838683d6cae77dbe587", size = 4324862, upload-time = "2026-10-26T12:29:54.68Z" },
{ url = "https://files.pythonhosted.org/packages/d7/18/a1b951d4ebfa939f10c54200909f8d667fb58eef1489d07d94aea7de9c46/pydantic_monty_client-1.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f633e1786bc12acd2b31f2a0fd90f955982ef0a06c9a2ef1d2e3d5832987fb11", size = 4053512, upload-time = "2026-10-26T12:29:56.205Z" },
{ url = "https://files.pythonhosted.org/packages/2f/64/297f1580de9ef26b975b73f3020546145cd50f6000abb275a643474d0515/pydantic_monty_client-1.4.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:87260bf6a226f5bbcc03e4a8883ac3a04bebb1c8a3e3da747ee8c464443e6f37", size = 4123580, upload-time = "2026-10-26T12:29:57.975Z" },
{ url = "https://files.pythonhosted.org/packages/68/00/93bd1fa14eabf97c59b983ba020ef951391ff2497c06babb2d6c61635aad/pydantic_monty_client-1.4.0-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:ffa480dab03bbc0ae7e59d13e894c42c89909837a7a2ee3d8e41967d192213b4", size = 3807832, upload-time = "2026-10-26T12:29:59.388Z" },
{ url = "https://files.pythonhosted.org/packages/9c/3f/4f2fbb788c71f346a73fd08fdb172053aa09cb382c51091e7058868a8c86/pydantic_monty_client-1.4.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:731499cad98261d0e8f1d125349ef268c2db9081b2cb25d8bef68d99c62ecd80", size = 4046624, upload-time = "2026-10-26T12:30:01.16Z" },
{ url = "https://files.pythonhosted.org/packages/56/41/8055a6d78c10399602cdf605a94146245a13d2a6ad519be65903f8ad9406/pydantic_monty_client-1.4.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:f50b807e1475e47cf813049c265070d3cdb76e8ef7e5b77b3e34908d15a8a4db", size = 4262907, upload-time = "2026-10-26T12:30:02.785Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c6/e466e7a374fdc54718c8bb664a1bfbec063733315254490f4b21877f0459/pydantic_monty_client-1.4.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:586d0f24faeabb6243584d2ce13bb2180a9607ac157a4e9e4edc78e40b2ac32f", size = 4163131, upload-time = "2026-10-26T12:30:04.228Z" },
{ url = "https://files.pythonhosted.org/packages/56/ac/3f868d04d3db7902ffcf0e4455498b5ad5baa73dbd1188fa978efc993750/pydantic_monty_client-1.4.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:41e7bd5b0b6ae8f1ac878f2c2a3c60656319fb3d51602bb638f72d5db0734711", size = 4416533, upload-time = "2026-10-26T12:30:05.78Z" },
{ url = "https://files.pythonhosted.org/packages/b7/27/913d7c73cdffb78ccd2fc516d05b0c392e48f61d42166910aff1fdf18d63/pydantic_monty_client-1.4.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fa992fa81e1d753bd1c637d90f5713a92d7ce25d968c6195e88219fdf8e628c2", size = 4293701, upload-time = "2026-10-26T12:30:07.249Z" },
{ url = "https://files.pythonhosted.org/packages/85/ef/ec082ca4405889d784d0c667a63c17c3755cc1b0c775bf7e801509cd4fc9/pydantic_monty_client-1.4.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:868d269d20b356d513968aeecbd69161adf8f2a59bc506aac463fff30bd73ab1", size = 4610590, upload-time = "2026-10-26T12:30:09.211Z" },
{ url = "https://files.pythonhosted.org/packages/23/18/a8b360113cb09539ba67e012471661325549f314d3634162b7afbdd0b07e/pydantic_monty_client-1.4.0-cp314-cp314-win32.whl", hash = "sha256:878aede0148c7a9b51bf20940157bbcad089991f60ff5bf27ed34f402d31332f", size = 3680393, upload-time = "2026-10-26T12:30:11.07Z" },
{ url = "https://files.pythonhosted.org/packages/91/ac/fd15b7e8e2517b94cf5c2ac9124f1871367a9e94843e89eb3dcc829bf72a/pydantic_monty_client-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:59f038a326bb3ee8716236a62119f54d7fb27c00a3f9e56ac06ae7d468a48284", size = 4349750, upload-time = "2026-10-26T12:30:12.813Z" },
{ url = "https://files.pythonhosted.org/packages/6c/eb/1d513581f8e8d0e5363edc3cad334cce69777f8abfcbbd6883510e8a53c5/pydantic_monty_client-1.4.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:69aec529dd526655cb5861d6080b2df4bca2c5ba2fbdfd46c2b3ddea4050f31d", size = 4312356, upload-time = "2026-10-26T12:30:14.585Z" },
{ url = "https://files.pythonhosted.org/packages/26/6e/2e9187b663f3cb222beb8c6b5722250d36c12b8b65b71642825ad9c5013f/pydantic_monty_client-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f1cc47e6fd4a163f5bbb88934aa84fbbfa8ebfb5f76932896467d49dabfaf905", size = 4041090, upload-time = "2026-10-26T12:30:16.314Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e3/f144d76a0956fa35487391f090af7cde4ffc48366f40945d2875b799bfd7/pydantic_monty_client-1.4.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:06290ce25c3b4a89941bdb9a08e1c42f2e288274ff98edcbd7fc2dbbbc2730c7", size = 4118219, upload-time = "2026-10-26T12:30:18.067Z" },
{ url = "https://files.pythonhosted.org/packages/c7/96/ae06f344a390a3cdb9aaff29cd89c6a28c680a8301f32b6d970873bac73e/pydantic_monty_client-1.4.0-cp314-cp314t-manylinux_2_28_armv7l.whl", hash = "sha256:4f7ed73540db9c17d6a64dcc1e823946444a44574883dac53c68cb93987dba97", size = 3795829, upload-time = "2026-10-26T12:30:19.521Z" },
{ url = "https://files.pythonhosted.org/packages/4c/54/023a9516f211a63f44881c5a4536f8827742541b5ccc50522c67712482b6/pydantic_monty_client-1.4.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:0493a65bc534445123834276bedf38baa4af964ca35b8fef66e440d6d23a1964", size = 4037589, upload-time = "2026-10-26T12:30:21.561Z" },
{ url = "https://files.pythonhosted.org/packages/09/d2/4aab45f23dad94fdea6705a236ff9eabf48577fcc6e4e80df031382cf231/pydantic_monty_client-1.4.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:41305b81fe9d61fcef7e9c6eabc7308053ebaa568a0ef9d21cc4715198392937", size = 4253700, upload-time = "2026-10-26T12:30:23.358Z" },
{ url = "https://files.pythonhosted.org/packages/09/40/fb1c9248efbd4b5e9d2219b63c3185ea3a0113f869415b56a489259ef3fe/pydantic_monty_client-1.4.0-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:1943eb2505ac1b80e9e676abb92d015d5406a7413fb6a4f6d364d44905a521c9", size = 4147967, upload-time = "2026-10-26T12:30:25.107Z" },
{ url = "https://files.pythonhosted.org/packages/75/79/3b7b33118d03998ba1dee714919bf9f5d8099ae0771aa75495dcad1e9dfe/pydantic_monty_client-1.4.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1d1e9db032a2ff431d44de84fb569b0736c3f21dc58c84ef974985dbc722a34c", size = 4413011, upload-time = "2026-10-26T12:30:28.146Z" },
{ url = "https://files.pythonhosted.org/packages/45/76/40656a25a297112aedbe4d34f7f745e90aeb8f11a3377cbefd3a344cc445/pydantic_monty_client-1.4.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:076b256cc3b8b0301934f8c12a38f43e549fc2f07b945bf9a9251dfaa9d11aeb", size = 4285647, upload-time = "2026-10-26T12:30:29.77Z" },
{ url = "https://files.pythonhosted.org/packages/44/f7/10b71b443fdfd977e2e60913a2928f562cef97eb13119ecec91a6a719084/pydantic_monty_client-1.4.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3d466a34a6d111b07cff2464796d681a043279cb956a4286e7bfb8c730d22ab5", size = 4606819, upload-time = "2026-10-26T12:30:31.382Z" },
{ url = "https://files.pythonhosted.org/packages/63/c3/1a41dfed2d0fe15965a57919a0266e8fed06b20513f5fe773ae7b7aa26af/pydantic_monty_client-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:fd7ccfa999bbcd05bd8b248ac2071c3d9b9a7e44b99e696596ee785e69aed91c", size = 3671748, upload-time = "2026-10-26T12:30:33.1Z" },
{ url = "https://files.pythonhosted.org/packages/01/b2/6a75a83dbc25e0244ac986ea33a822af7b6f0ca363b5c4ee852fd26fe396/pydantic_monty_client-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8ad5d44fec1b0f60f68209eeff600fc8673dc6960d070bb05e307c6aba35c4fa", size = 4344965, upload-time = "2026-10-26T12:30:34.712Z" },
{ url = "https://files.pythonhosted.org/packages/24/86/61e5de22b9fb6963adba6d0b53b7aebd4a89f01a744f63596e9dbe56a5a0/pydantic_monty_client-1.4.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1a8907baeca0f0bf0d257620bbe37f6867ac6a7ff2cd677591e6ad52b3ba8667", size = 4324609, upload-time = "2026-10-26T12:30:36.236Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/e766f4c65c47e18ed41dec5ceb3c795ec843c2c03f0d9cc0df4b00bdc52a/pydantic_monty_client-1.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7e6423fc6026378c241e20d3cce48e549d1cbff744c1622f0bf625f85b2c3789", size = 4053763, upload-time = "2026-10-26T12:30:37.664Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ed/419fbd3627764e67b9bacf8af72d1e28a214759b6efcbabcb03d3a373aae/pydantic_monty_client-1.4.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:f87a316222f38755b904902fe8c3c4e581165af25bcf872da741c354f004dd9d", size = 4123545, upload-time = "2026-10-26T12:30:39.322Z" },
{ url = "https://files.pythonhosted.org/packages/2a/8d/40696d7bd4c5af8be86275943a98311beb011dd8aec6805b2a163924b4b4/pydantic_monty_client-1.4.0-cp315-cp315-manylinux_2_28_armv7l.whl", hash = "sha256:7fdffd11c96710c81b85f7e35faa4641bf6edcee092724850344e2045c88ce6d", size = 3808033, upload-time = "2026-10-26T12:30:40.822Z" },
{ url = "https://files.pythonhosted.org/packages/b5/51/9242b4f151db9aa653e974dee222fb7a7f660b5474e17203e1668d3ff2b7/pydantic_monty_client-1.4.0-cp315-cp315-manylinux_2_28_i686.whl", hash = "sha256:b24b44316bbb14dab9088fdcfa76f66ee92107b9e429186d2921aa2142f697e8", size = 4046971, upload-time = "2026-10-26T12:30:42.479Z" },
{ url = "https://files.pythonhosted.org/packages/35/7a/6619c87bb695b9cc0cc727ee427e1617812bec184dffa0fdc6441e0795f2/pydantic_monty_client-1.4.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:ec085c9c5d66a540067148dabc4d894d160741277947a57ac4179c5607a5bc2c", size = 4264420, upload-time = "2026-10-26T12:30:44.554Z" },
{ url = "https://files.pythonhosted.org/packages/4a/90/b941236f60533f48c077c54b810d9f47d5c75b71f5057c13a1e4e1091905/pydantic_monty_client-1.4.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:2d80aed4644d4ff907324582def94e76151ad862198776d544d760db8dc57219", size = 4163434, upload-time = "2026-10-26T12:30:46.765Z" },
{ url = "https://files.pythonhosted.org/packages/0a/a0/31b38b607c9a22dac273f860b21fb21e591b126a1ae251fb4a54bf110d43/pydantic_monty_client-1.4.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:e459c3722c5db566b5f4295f5c5862e77eb0d09abb49b1feac9b0e39467c6fae", size = 4416755, upload-time = "2026-10-26T12:30:48.826Z" },
{ url = "https://files.pythonhosted.org/packages/50/c6/e07dccc12c598739fe442881b71d0d41293713f2eb2bd4f8f63270c0a854/pydantic_monty_client-1.4.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:7130cf0b75557301764b57925da46cbaecae68440ea7b9401bc2d2b0e6bf9dd7", size = 4293680, upload-time = "2026-10-26T12:30:50.496Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5f/be7b09bf2e159afcd91932dcd5f8e8878cdbdb2fec863b81824e8d370d3f/pydantic_monty_client-1.4.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:29f76fb33adf72dda3277adeae13b11340178157b1ab0b544f8f13e9d032780c", size = 4610741, upload-time = "2026-10-26T12:30:52.095Z" },
{ url = "https://files.pythonhosted.org/packages/39/d5/bdefd85f079cbf6a6c8aea2b8b086f6a3ebd48d3291866aa45c6c2741bb9/pydantic_monty_client-1.4.0-cp315-cp315-win32.whl", hash = "sha256:7e073a55ac45eb001e2e702dca94b2bed882ee5fe65898642dd6f1e116589f93", size = 3680779, upload-time = "2026-10-26T12:30:53.66Z" },
{ url = "https://files.pythonhosted.org/packages/ab/4e/dc9403b65e914bba1b9507e33a52b243a6c1d83a8b85087306d71b412b7c/pydantic_monty_client-1.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:f928cc867284e2f366c2b2677590baceccd1800ff34aab82221df9a7e3f58336", size = 4349931, upload-time = "2026-10-26T12:30:55.241Z" },
{ url = "https://files.pythonhosted.org/packages/12/4b/a032b0d12627c182b4b52cf9cd6e34a3c5fceaa635a2ce8b66ce3e753205/pydantic_monty_client-1.4.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d0fbba5046db61eb82a41425c30c1317029694c68fe864cec8b05aff5fc8abc", size = 4312356, upload-time = "2026-10-26T12:30:56.765Z" },
{ url = "https://files.pythonhosted.org/packages/4c/90/4bc4258da5889fb7cd958f5e9fc79d254d51c5a9caba732abf48113e5332/pydantic_monty_client-1.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:639f4a7a8fb8f1978f9036734f7b8bc68b0f48074a91f9f2360fe51fb0023b78", size = 4041241, upload-time = "2026-10-26T12:30:58.258Z" },
{ url = "https://files.pythonhosted.org/packages/04/20/c8c385e7b756d284e1a6c77bf9cf2b1d7c41b5eeec033dc01c0bff38ab6f/pydantic_monty_client-1.4.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:cc66b78f433dd67ea4d9697fa50b4d5d24d7f6a96e99cff162ca1d46e89998bd", size = 4118063, upload-time = "2026-10-26T12:30:59.943Z" },
{ url = "https://files.pythonhosted.org/packages/61/0d/61bf503046e0954675e746ca468d1115c682125d96fb05e41a821b93f32d/pydantic_monty_client-1.4.0-cp315-cp315t-manylinux_2_28_armv7l.whl", hash = "sha256:74579bd3139c6c6cc707199b6956f97dbdf16e7c7ce4a802e1af64d48e10e68b", size = 3795982, upload-time = "2026-10-26T12:31:01.494Z" },
{ url = "https://files.pythonhosted.org/packages/e1/4a/11deac6408b5bfca8d9e2150802e366b59f80d5cba37b5d4a2bd99f9a545/pydantic_monty_client-1.4.0-cp315-cp315t-manylinux_2_28_i686.whl", hash = "sha256:527a10831d7bb56b728c878c8c80ee0c7cdcfbf1e4c3b82474e8b58d31beeba3", size = 4037807, upload-time = "2026-10-26T12:31:02.884Z" },
{ url = "https://files.pythonhosted.org/packages/bc/5e/16d5ed8a6d55d80847808248734ba0a7f68e5d4f077f46f4c5924344f8aa/pydantic_monty_client-1.4.0-cp315-cp315t-manylinux_2_28_ppc64le.whl", hash = "sha256:59bac93edeeb060b11b0b4d74bfb2afbd7548e8f67d70bc132d7526a4e4177fd", size = 4254709, upload-time = "2026-10-26T12:31:04.463Z" },
{ url = "https://files.pythonhosted.org/packages/9d/21/02c8a175b3fdc7098dcc2c25e6e0faff89c8346c86ea6b070ada9ecee2bd/pydantic_monty_client-1.4.0-cp315-cp315t-manylinux_2_28_s390x.whl", hash = "sha256:ee32f3242000c4020e5708abe1e4c36615c0ee4ac5b8d87318079f76a794e109", size = 4148932, upload-time = "2026-10-26T12:31:06.012Z" },
{ url = "https://files.pythonhosted.org/packages/54/32/1e97c4c2b4b35fbdd8c952f756038e6a92d354f9e30eeee2d5226daadad2/pydantic_monty_client-1.4.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:8cd489ea1fab085164185284cad43aa8780833d79e9e2f80071dd8494e5c4dd5", size = 4413415, upload-time = "2026-10-26T12:31:07.609Z" },
{ url = "https://files.pythonhosted.org/packages/a9/2f/11644f09adf9ebfe98a9e4a42f5d8583f4a465860754da1fd9926870f27b/pydantic_monty_client-1.4.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:02bdae4755e7c90ecc32b15ea68e13a527ea6f9d6574a54a92d0242ec2a2866a", size = 4285838, upload-time = "2026-10-26T12:31:09.182Z" },
{ url = "https://files.pythonhosted.org/packages/a7/32/528b03acf62fdcea145f7f7b2ea9b7a33a6364fd734874558f448f5c07c7/pydantic_monty_client-1.4.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:4412a0adc72e4c51bc3f23993500856523498676936f3eeab26d223bce27314b", size = 4607062, upload-time = "2026-10-26T12:31:11.578Z" },
{ url = "https://files.pythonhosted.org/packages/4b/62/72379b965360e953cb45c6aebc61605951f6b2e0e6d703571dcd780d6518/pydantic_monty_client-1.4.0-cp315-cp315t-win32.whl", hash = "sha256:29cdd6fc5c4ffd0e6580455185a138b63b7ffddbc4eda1f468e8ae96f611a4c3", size = 3671769, upload-time = "2026-10-26T12:31:13.115Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/ab795155a741ff3208ddb6f6c6475ccbb5cb65384329f4d0bad079443748/pydantic_monty_client-1.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c7111b4239f745b63910212c3104c9f91a8f5ee1b7c64ebcc18286117d7cf1d6", size = 4344987, upload-time = "2026-10-26T12:31:14.729Z" },
]
[[package]]
name = "pydantic-monty-runtime"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/e7/1567009ab17fe091caba156ea9406487520ef382543e73ac7354a4eef88a/pydantic_monty_runtime-1.4.0.tar.gz", hash = "sha256:5275beb451201bd23669a253c8be93b90cad018b449fcdd0a1ad57daeed15187", size = 2581228, upload-time = "2026-10-26T12:31:53.171Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/6e/654bf4e3867e48564c7f4dabf03023660c860f53a0065915c4fa1e816825/pydantic_monty_runtime-1.4.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ba7ad3163cbf601d7d36e5c9b6edb7b1625f27683a910885682d224edb8c05e", size = 12643236, upload-time = "2026-10-26T12:31:16.785Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9b/0eedee19d738a168cb4fbcefb1be38cb7c729c750d0293379483e38fa71a/pydantic_monty_runtime-1.4.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:317edf6c62ea62c8a0f456ec13be889387cd16a917441fc087953cae042e6d75", size = 13763884, upload-time = "2026-10-26T12:31:19.94Z" },
{ url = "https://files.pythonhosted.org/packages/d6/91/613e52c0483bb6cbc92ad443450b6573b0d55924941e5235c72edfd08eef/pydantic_monty_runtime-1.4.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fabe933bb157910aeb61b705c19500d8675b4033215bdb93bc7da1ee9a22a6c9", size = 14209091, upload-time = "2026-10-26T12:31:23.568Z" },
{ url = "https://files.pythonhosted.org/packages/35/cd/326b9d2333d3f18a1642ed0c6721f9454a1672a469b316c92942d764363e/pydantic_monty_runtime-1.4.0-py3-none-manylinux_2_28_armv7l.whl", hash = "sha256:b33e86f80adeb86f7928c5040602049d9a245d7cf08ff1398b2ee577fbf7d6fb", size = 13279002, upload-time = "2026-10-26T12:31:26.429Z" },
{ url = "https://files.pythonhosted.org/packages/66/d6/b9ab4adaa305b2aaa5624a9ac5b9a21fb65312c44f8dd658d336d2e20356/pydantic_monty_runtime-1.4.0-py3-none-manylinux_2_28_i686.whl", hash = "sha256:be875b3d248df50768ef9d2c420252e14458ec6f99a1ba2f10e34b0a2f7d88c1", size = 13509495, upload-time = "2026-10-26T12:31:28.996Z" },
{ url = "https://files.pythonhosted.org/packages/e9/e9/d812921782424db588b9772f57558df4ad8e58c65125d5101f511a08180d/pydantic_monty_runtime-1.4.0-py3-none-manylinux_2_28_ppc64le.whl", hash = "sha256:2be251cc1ee73337dbe3a7ea00d657e4a8385f2476523e74c5685d8f1fc2fea4", size = 13887394, upload-time = "2026-10-26T12:31:31.547Z" },
{ url = "https://files.pythonhosted.org/packages/20/60/2ebff28552652f00f4a8b7266570326f8e03383d44a9e06d492501cbaed5/pydantic_monty_runtime-1.4.0-py3-none-manylinux_2_28_s390x.whl", hash = "sha256:7e13192106dea260dc605a371ab44ffd248dc5afe0ab003f873779774c73a7e6", size = 14964165, upload-time = "2026-10-26T12:31:34.635Z" },
{ url = "https://files.pythonhosted.org/packages/d3/4a/fdf0ff01c3ee23f5af7a017e970967fc88d6d263a79db03d3dfce5c028a1/pydantic_monty_runtime-1.4.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:ff6301049a8e2f3407188dd174a749f2f2af6f82a2bc993c707dbebf44dcf651", size = 14726134, upload-time = "2026-10-26T12:31:37.607Z" },
{ url = "https://files.pythonhosted.org/packages/80/85/317a61daca57f3211d53f868977dbd1d491e0212082f0e79cd6df50137de/pydantic_monty_runtime-1.4.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:ae4e68d623b9038f8a41baa95e06e8d32402ad972120b068d14c336bb924eb03", size = 12315943, upload-time = "2026-10-26T12:31:40.22Z" },
{ url = "https://files.pythonhosted.org/packages/50/c4/fc238425268af5ca9642d5ae997cb1101c95b87e7876ceabf8275b42a1d3/pydantic_monty_runtime-1.4.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:af1f5d41c32207a2ecd9a9c4277290911c6e5f37299588097c7ff83240bb665c", size = 13021113, upload-time = "2026-10-26T12:31:42.899Z" },
{ url = "https://files.pythonhosted.org/packages/24/be/2d221328cd4017b3cfefb6c197740e56ab02689ac1300ad883450a15d565/pydantic_monty_runtime-1.4.0-py3-none-win32.whl", hash = "sha256:30c393985843e836b32e1ceb18c251661e54984bb1e992b7a88d5e897679af01", size = 12392040, upload-time = "2026-10-26T12:31:45.422Z" },
{ url = "https://files.pythonhosted.org/packages/5f/b2/5ac23283be20db8b127c42bb1072910d809792c98cb5b626a85e6ffdcc09/pydantic_monty_runtime-1.4.0-py3-none-win_amd64.whl", hash = "sha256:c2f5a7503288087669f7c5c6131dc9b68a26dfabf07d5fb36df33ff475afbe61", size = 13250757, upload-time = "2026-10-26T12:31:48.107Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.14.2"