mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Add Monty sandbox adapter for workflow Python nodes (wip slice)
Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
parent
5085022580
commit
734a136a88
8 changed files with 1327 additions and 0 deletions
11
litellm/proxy/workflows/monty/AGENTS.md
Normal file
11
litellm/proxy/workflows/monty/AGENTS.md
Normal 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
|
||||
358
litellm/proxy/workflows/monty/compiler.py
Normal file
358
litellm/proxy/workflows/monty/compiler.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import ast
|
||||
import hashlib
|
||||
import keyword
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import SchemaError
|
||||
from pydantic import JsonValue
|
||||
|
||||
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_"
|
||||
_GENERATED_GLOBALS: Final = frozenset({"dataclass", "str", "int", "float", "bool", "list", "dict", "all", "isinstance"})
|
||||
|
||||
|
||||
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 _source_error(tree: ast.Module, program: PythonProgram) -> MontyFailure | None:
|
||||
for statement in tree.body:
|
||||
if (
|
||||
isinstance(statement, ast.Expr)
|
||||
and isinstance(statement.value, ast.Constant)
|
||||
and isinstance(statement.value.value, str)
|
||||
):
|
||||
continue
|
||||
if isinstance(statement, ast.ImportFrom) and statement.module == "__future__" and statement.level == 0:
|
||||
if all(alias.name == "annotations" and alias.asname is None for alias in statement.names):
|
||||
continue
|
||||
if not isinstance(statement, ast.FunctionDef):
|
||||
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])
|
||||
for node in ast.walk(tree):
|
||||
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):
|
||||
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)
|
||||
if node.args.defaults or any(default is not None for default in node.args.kw_defaults):
|
||||
return _invalid("function defaults are forbidden", node)
|
||||
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 _mapping(value: JsonValue) -> JsonSchema:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _resolve(schema: JsonSchema, root: JsonSchema) -> JsonSchema:
|
||||
ref: Final = schema.get("$ref")
|
||||
if not isinstance(ref, str):
|
||||
return schema
|
||||
|
||||
def descend(value: JsonValue, parts: tuple[str, ...]) -> JsonSchema:
|
||||
if not parts:
|
||||
return _mapping(value)
|
||||
if isinstance(value, dict):
|
||||
return descend(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 descend(value[int(parts[0])], parts[1:])
|
||||
return {}
|
||||
|
||||
return descend(root, tuple(ref[2:].split("/")))
|
||||
|
||||
|
||||
def _schema_error(value: JsonValue) -> str | None:
|
||||
if isinstance(value, list):
|
||||
return next((error for item in value if (error := _schema_error(item)) is not None), None)
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
for key, item in value.items():
|
||||
if key in {"$id", "$dynamicRef", "$dynamicAnchor", "$anchor", "$schema"}:
|
||||
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 not in {"const", "enum", "default", "examples"}:
|
||||
if (error := _schema_error(item)) is not None:
|
||||
return error
|
||||
return None
|
||||
|
||||
|
||||
def _record_error(schema: JsonSchema, root: JsonSchema, seen: frozenset[str] = frozenset()) -> str | None:
|
||||
ref: Final = schema.get("$ref")
|
||||
if isinstance(ref, str):
|
||||
if ref in seen:
|
||||
return "recursive schema references are unsupported"
|
||||
return _record_error(_resolve(schema, root), root, seen | {ref})
|
||||
if schema.get("x-liteflow-record") is True:
|
||||
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"
|
||||
for keyword_name in ("anyOf", "oneOf", "allOf"):
|
||||
alternatives: Final = schema.get(keyword_name)
|
||||
if isinstance(alternatives, list):
|
||||
if any(_contains_record(_mapping(item), root, seen) for item in alternatives):
|
||||
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"
|
||||
for key, value in schema.items():
|
||||
if key in {"properties", "$defs"} and isinstance(value, dict):
|
||||
for child in value.values():
|
||||
if (error := _record_error(_mapping(child), root, seen)) is not None:
|
||||
return error
|
||||
if key in {"anyOf", "oneOf", "allOf", "prefixItems"} and isinstance(value, list):
|
||||
for child_schema in value:
|
||||
if (error := _record_error(_mapping(child_schema), root, seen)) is not None:
|
||||
return error
|
||||
if key in {"items", "additionalProperties"} and isinstance(value, dict):
|
||||
if (error := _record_error(value, root, seen)) is not None:
|
||||
return error
|
||||
return None
|
||||
|
||||
|
||||
def _contains_record(schema: JsonSchema, root: JsonSchema, seen: frozenset[str]) -> bool:
|
||||
ref: Final = schema.get("$ref")
|
||||
if isinstance(ref, str):
|
||||
return ref not in seen and _contains_record(_resolve(schema, root), root, seen | {ref})
|
||||
if schema.get("x-liteflow-record") is True:
|
||||
return True
|
||||
return any(_contains_record(child, root, seen) for child in _children(schema))
|
||||
|
||||
|
||||
def _children(schema: JsonSchema) -> tuple[JsonSchema, ...]:
|
||||
def children_of(key: str, value: JsonValue) -> tuple[JsonSchema, ...]:
|
||||
if key in {"properties", "$defs"} and isinstance(value, dict):
|
||||
return tuple(_mapping(child) for child in value.values())
|
||||
if key in {"anyOf", "oneOf", "allOf", "prefixItems"} and isinstance(value, list):
|
||||
return tuple(_mapping(child) for child in value)
|
||||
if key in {"items", "additionalProperties"} and isinstance(value, dict):
|
||||
return (value,)
|
||||
return ()
|
||||
|
||||
return tuple(child for group in (children_of(key, value) for key, value in schema.items()) for child in group)
|
||||
|
||||
|
||||
def _records(root: JsonSchema) -> tuple[tuple[str, JsonSchema], ...]:
|
||||
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 _build(schema: JsonSchema, root: JsonSchema, expression: str, depth: int = 0) -> str:
|
||||
resolved: Final = _resolve(schema, root)
|
||||
ref: Final = schema.get("$ref")
|
||||
if isinstance(ref, str) and "$ref" in resolved:
|
||||
return _build(resolved, root, expression, depth + 1)
|
||||
union: Final = resolved.get("anyOf")
|
||||
if isinstance(union, list) and _contains_record(resolved, root, frozenset()):
|
||||
other: Final = next(_mapping(item) for item in union if _mapping(item).get("type") != "null")
|
||||
return f"(None if {expression} is None else {_build(other, root, expression, depth + 1)})"
|
||||
if resolved.get("x-liteflow-record") is True:
|
||||
name: Final = next(name for name, candidate in _records(root) 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 = _build(_mapping(child), root, access, 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"[{_build(_mapping(resolved.get('items')), root, variable, 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"{_build(_mapping(child), root, variable_value, depth + 1)} if {variable_key} == {key!r} else "
|
||||
for key, child in fields.items()
|
||||
)
|
||||
fallback: Final = _build(_mapping(resolved.get("additionalProperties")), root, variable_value, 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) -> str:
|
||||
required: Final = schema.get("required", [])
|
||||
fields: Final = _mapping(schema.get("properties"))
|
||||
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) -> str:
|
||||
records: Final = dict(_records(program.input_schema) + _records(program.output_schema))
|
||||
classes: Final = "\n".join(_record_class(name, schema) 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(
|
||||
_build(
|
||||
_mapping(_mapping(program.input_schema.get("properties")).get(name)),
|
||||
program.input_schema,
|
||||
f"{_PREFIX}input[{name!r}]",
|
||||
)
|
||||
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 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
|
||||
for schema in (program.input_schema, program.output_schema):
|
||||
if (error := _schema_error(schema)) is not None:
|
||||
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, escape_diagnostics(error))
|
||||
Draft202012Validator.check_schema(schema)
|
||||
if (record_error := _record_error(schema, schema)) is not None:
|
||||
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, escape_diagnostics(record_error))
|
||||
for name, record in _records(schema):
|
||||
if (
|
||||
not _safe_name(name)
|
||||
or name in _GENERATED_GLOBALS
|
||||
or name in {node.name for node in tree.body if isinstance(node, ast.FunctionDef)}
|
||||
):
|
||||
return MontyFailure(
|
||||
MontyErrorKind.UNSUPPORTED_SCHEMA, "record class name must be a distinct safe identifier"
|
||||
)
|
||||
input_records: Final = dict(_records(program.input_schema))
|
||||
if any(
|
||||
name in input_records and input_records[name] != schema for name, schema in _records(program.output_schema)
|
||||
):
|
||||
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, "conflicting record definitions")
|
||||
inputs: Final = program.input_schema
|
||||
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(inputs.get("required"), list)
|
||||
or set(inputs["required"]) != set(program.parameter_names)
|
||||
):
|
||||
return MontyFailure(
|
||||
MontyErrorKind.UNSUPPORTED_SCHEMA, "input schema must require exactly the named parameters"
|
||||
)
|
||||
return CompiledProgram(program, _source(program), 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, ValueError, RecursionError, StopIteration) as exc:
|
||||
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, escape_diagnostics(str(exc)))
|
||||
76
litellm/proxy/workflows/monty/models.py
Normal file
76
litellm/proxy/workflows/monty/models.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
||||
|
||||
JsonSchema = dict[str, JsonValue]
|
||||
|
||||
|
||||
class PythonProgram(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
contract_version: Literal["1"]
|
||||
source: str
|
||||
function_name: str
|
||||
parameter_names: Annotated[tuple[str, ...], Field(min_length=1)]
|
||||
input_schema: JsonSchema
|
||||
output_schema: JsonSchema
|
||||
source_sha256: str
|
||||
|
||||
|
||||
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)
|
||||
|
||||
max_source_bytes: int = Field(default=64 * 1024, gt=0)
|
||||
max_input_bytes: int = Field(default=1024 * 1024, gt=0)
|
||||
max_output_bytes: int = Field(default=1024 * 1024, gt=0)
|
||||
max_input_depth: int = Field(default=64, gt=0)
|
||||
max_output_depth: int = Field(default=64, gt=0)
|
||||
max_memory: int = Field(default=64 * 1024 * 1024, gt=0)
|
||||
worker_address_space_bytes: int = Field(default=512 * 1024 * 1024, gt=0)
|
||||
feed_seconds: float = Field(default=5, gt=0)
|
||||
max_recursion_depth: int = Field(default=200, gt=0)
|
||||
max_stdout_bytes: int = Field(default=64 * 1024, gt=0)
|
||||
max_concurrent_workers: int = Field(default=4, gt=0)
|
||||
max_queued_waiters: int = Field(default=16, ge=0)
|
||||
255
litellm/proxy/workflows/monty/runtime.py
Normal file
255
litellm/proxy/workflows/monty/runtime.py
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
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 jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import ValidationError
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
from .compiler import escape_diagnostics
|
||||
from .models import CompiledProgram, JsonSchema, MontyErrorKind, MontyFailure, MontyLimits, MontySuccess
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic_monty import AsyncMonty, AsyncMontySession
|
||||
|
||||
_JSON: Final = 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(distribution.locate_file(file)).resolve(), digest)
|
||||
except (importlib.metadata.PackageNotFoundError, OSError, ValueError) as exc:
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, escape_diagnostics(str(exc)))
|
||||
|
||||
|
||||
def _json_issue(value: object, 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 := _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(
|
||||
(issue for item in value.values() if (issue := _json_issue(item, depth + 1, max_depth)) is not None), None
|
||||
)
|
||||
return MontyErrorKind.INPUT_INVALID
|
||||
|
||||
|
||||
def validate_json(
|
||||
value: object, schema: JsonSchema, *, 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
|
||||
issue: Final = _json_issue(value, 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"
|
||||
)
|
||||
try:
|
||||
encoded: Final = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) > max_bytes:
|
||||
return MontyFailure(too_large, "JSON byte limit exceeded")
|
||||
parsed: Final = _JSON.validate_python(value, strict=True)
|
||||
Draft202012Validator(schema).validate(parsed)
|
||||
return parsed
|
||||
except (ValidationError, ValueError, OverflowError, RecursionError) as exc:
|
||||
return MontyFailure(invalid, escape_diagnostics(str(exc))[:4096])
|
||||
|
||||
|
||||
def _kill(session: AsyncMontySession) -> None:
|
||||
pid: Final = session.worker_pid
|
||||
if pid is None:
|
||||
return
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
|
||||
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
|
||||
|
||||
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__()
|
||||
self._pool = pool
|
||||
return None
|
||||
except (ImportError, OSError, ValueError, RuntimeError) as exc:
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, escape_diagnostics(str(exc)))
|
||||
|
||||
async def close(self) -> None:
|
||||
pool: Final = self._pool
|
||||
self._pool = None
|
||||
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:
|
||||
validated: Final = validate_json(
|
||||
input,
|
||||
compiled.input_schema,
|
||||
max_bytes=self.limits.max_input_bytes,
|
||||
max_depth=self.limits.max_input_depth,
|
||||
)
|
||||
if isinstance(validated, MontyFailure):
|
||||
return validated
|
||||
return await self._execute(compiled, validated, deadline_monotonic, started)
|
||||
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
|
||||
) -> 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 sys.platform == "linux":
|
||||
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, (self.limits.worker_address_space_bytes,) * 2)
|
||||
except (OSError, ValueError) as exc:
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, escape_diagnostics(str(exc)))
|
||||
result: Final[object] = await session.feed_run(
|
||||
compiled.sandbox_source,
|
||||
inputs={"_liteflow_input": input},
|
||||
print_callback=collector,
|
||||
)
|
||||
if not isinstance(result, list) or len(result) != 2 or result[0] is not True:
|
||||
return MontyFailure(MontyErrorKind.OUTPUT_INVALID, "return value is not strict JSON")
|
||||
output: Final = validate_json(
|
||||
result[1],
|
||||
compiled.output_schema,
|
||||
max_bytes=self.limits.max_output_bytes,
|
||||
max_depth=self.limits.max_output_depth,
|
||||
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(collector.output), int((time.monotonic() - started) * 1000)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
_kill(session)
|
||||
raise
|
||||
except MontyRuntimeError as exc:
|
||||
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 "bytes >" in str(error) else MontyErrorKind.MEMORY_LIMIT
|
||||
return MontyFailure(kind, message)
|
||||
if isinstance(error, RecursionError):
|
||||
return MontyFailure(MontyErrorKind.RECURSION_LIMIT, message)
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_ERROR, message)
|
||||
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 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
|
||||
|
|
@ -55,6 +55,7 @@ Documentation = "https://docs.litellm.ai"
|
|||
# supply-chain-pinned to a vetted, aged release.
|
||||
[project.optional-dependencies]
|
||||
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'",
|
||||
|
|
|
|||
335
tests/integration/sandbox/test_monty_runtime.py
Normal file
335
tests/integration/sandbox/test_monty_runtime.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
async def runtime() -> AsyncIterator[MontyRuntime]:
|
||||
instance: Final = MontyRuntime(MontyLimits(max_concurrent_workers=1, max_queued_waiters=0))
|
||||
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(tmp_path: Path) -> 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
|
||||
|
||||
|
||||
def _worker_pids() -> frozenset[int]:
|
||||
children: Final = Path(f"/proc/{os.getpid()}/task/{os.getpid()}/children").read_text().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)
|
||||
full: Final = await _run(runtime, _compile("def transform(value): return value"), 1)
|
||||
assert isinstance(full, MontyFailure)
|
||||
assert full.kind == MontyErrorKind.QUEUE_FULL
|
||||
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)
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
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
|
||||
148
tests/unit/proxy/workflows/monty/test_compiler.py
Normal file
148
tests/unit/proxy/workflows/monty/test_compiler.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import hashlib
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.workflows.monty.compiler import 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",
|
||||
"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())
|
||||
if source == "def transform(value): return":
|
||||
assert isinstance(result, CompiledProgram)
|
||||
return
|
||||
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"},
|
||||
{"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"
|
||||
143
uv.lock
generated
143
uv.lock
generated
|
|
@ -5090,6 +5090,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'" },
|
||||
|
|
@ -5349,6 +5350,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" },
|
||||
|
|
@ -8785,6 +8787,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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue