diff --git a/.github/scripts/assert_ci_coverage.py b/.github/scripts/assert_ci_coverage.py index b442948c3ca..83f29b27973 100644 --- a/.github/scripts/assert_ci_coverage.py +++ b/.github/scripts/assert_ci_coverage.py @@ -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 diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index 73159bd64d2..7ab63905437 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -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 diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index fb29a92e306..ca8d3be78ce 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -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 diff --git a/litellm/proxy/workflows/monty/AGENTS.md b/litellm/proxy/workflows/monty/AGENTS.md new file mode 100644 index 00000000000..8bd696872e0 --- /dev/null +++ b/litellm/proxy/workflows/monty/AGENTS.md @@ -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 diff --git a/litellm/proxy/workflows/monty/_validator_worker.py b/litellm/proxy/workflows/monty/_validator_worker.py new file mode 100644 index 00000000000..52b422ea2bc --- /dev/null +++ b/litellm/proxy/workflows/monty/_validator_worker.py @@ -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() diff --git a/litellm/proxy/workflows/monty/compiler.py b/litellm/proxy/workflows/monty/compiler.py new file mode 100644 index 00000000000..9f8af647641 --- /dev/null +++ b/litellm/proxy/workflows/monty/compiler.py @@ -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))) diff --git a/litellm/proxy/workflows/monty/models.py b/litellm/proxy/workflows/monty/models.py new file mode 100644 index 00000000000..62abae01e72 --- /dev/null +++ b/litellm/proxy/workflows/monty/models.py @@ -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) diff --git a/litellm/proxy/workflows/monty/runtime.py b/litellm/proxy/workflows/monty/runtime.py new file mode 100644 index 00000000000..4a85e2b10ee --- /dev/null +++ b/litellm/proxy/workflows/monty/runtime.py @@ -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 diff --git a/litellm/proxy/workflows/monty/validation.py b/litellm/proxy/workflows/monty/validation.py new file mode 100644 index 00000000000..f8df23fc4b3 --- /dev/null +++ b/litellm/proxy/workflows/monty/validation.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 02834f604d2..207a99e7c50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index f4ca7fa2965..e966837a5f1 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -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", diff --git a/tests/integration/AGENTS.md b/tests/integration/AGENTS.md index e5068d24e88..aa9b091e0b4 100644 --- a/tests/integration/AGENTS.md +++ b/tests/integration/AGENTS.md @@ -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: ")` 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 diff --git a/tests/integration/README.md b/tests/integration/README.md index c5d52922de6..167f9cf5af6 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -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/.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 `/`, while the test-owned cost map is served over loopback through `LITELLM_MODEL_COST_MAP_URL` diff --git a/tests/integration/run.py b/tests/integration/run.py index 7ab8d59c02c..b987682b223 100644 --- a/tests/integration/run.py +++ b/tests/integration/run.py @@ -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", diff --git a/tests/integration/sandbox/test_monty_runtime.py b/tests/integration/sandbox/test_monty_runtime.py new file mode 100644 index 00000000000..107d50e40f8 --- /dev/null +++ b/tests/integration/sandbox/test_monty_runtime.py @@ -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 diff --git a/tests/unit/proxy/workflows/__init__.py b/tests/unit/proxy/workflows/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/proxy/workflows/monty/__init__.py b/tests/unit/proxy/workflows/monty/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/proxy/workflows/monty/test_compiler.py b/tests/unit/proxy/workflows/monty/test_compiler.py new file mode 100644 index 00000000000..9da2fea9e77 --- /dev/null +++ b/tests/unit/proxy/workflows/monty/test_compiler.py @@ -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" diff --git a/tests/unit/proxy/workflows/monty/test_models.py b/tests/unit/proxy/workflows/monty/test_models.py new file mode 100644 index 00000000000..cb059f63e3d --- /dev/null +++ b/tests/unit/proxy/workflows/monty/test_models.py @@ -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) diff --git a/uv.lock b/uv.lock index 8712b78bbd6..c955519d95f 100644 --- a/uv.lock +++ b/uv.lock @@ -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"