mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge 2a802eb8b9 into 5103d8a0ee
This commit is contained in:
commit
5b838cc2f6
20 changed files with 2369 additions and 4 deletions
4
.github/scripts/assert_ci_coverage.py
vendored
4
.github/scripts/assert_ci_coverage.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
27
.github/workflows/test-integration.yml
vendored
27
.github/workflows/test-integration.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
11
litellm/proxy/workflows/monty/AGENTS.md
Normal file
11
litellm/proxy/workflows/monty/AGENTS.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Monty security boundary
|
||||
|
||||
Never pass host callbacks, mounts, environment variables, credentials, or live SDK objects into Monty
|
||||
|
||||
Never import, exec, or eval uploaded source on the gateway. Inspect it with AST only
|
||||
|
||||
Never fall back to CPython execution. Missing or unverified workers fail closed
|
||||
|
||||
Monty's max_memory is an allocator budget, not an RSS limit. Apply the worker address-space limit separately
|
||||
|
||||
Language isolation is not an OS security boundary. An interpreter escape requires separate OS-level containment
|
||||
50
litellm/proxy/workflows/monty/_validator_worker.py
Normal file
50
litellm/proxy/workflows/monty/_validator_worker.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from typing import Final, NoReturn, TypeAlias, cast # noqa: TID251 # stdlib decoder produces the recursive JSON union
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import ValidationError
|
||||
from referencing import Registry
|
||||
from referencing.exceptions import NoSuchResource, Unresolvable
|
||||
from referencing.jsonschema import Schema
|
||||
|
||||
JsonValue: TypeAlias = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
MAX_MESSAGE_BYTES: Final = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def refuse_retrieval(uri: str) -> NoReturn:
|
||||
raise NoSuchResource(ref=uri)
|
||||
|
||||
|
||||
def closed_registry() -> Registry[Schema]:
|
||||
return Registry(retrieve=refuse_retrieval)
|
||||
|
||||
|
||||
def validate_request(line: bytes) -> Mapping[str, JsonValue]:
|
||||
try:
|
||||
request: Final = cast(JsonValue, json.loads(line)) # cast-ok: stdlib JSON decoder returns only JSON values
|
||||
if not isinstance(request, dict) or not isinstance(schema := request.get("schema"), dict):
|
||||
return {"valid": False, "message": "invalid validator request"}
|
||||
validator: Final = Draft202012Validator(schema, registry=closed_registry())
|
||||
validator.validate(request.get("value")) # pyright: ignore[reportUnknownMemberType] # upstream kwargs are untyped
|
||||
except (ValidationError, Unresolvable, ValueError, RecursionError) as exc:
|
||||
return {"valid": False, "message": str(exc)[:4096]}
|
||||
else:
|
||||
return {"valid": True, "message": ""}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sys.stdout.buffer.write(b"ready\n")
|
||||
sys.stdout.buffer.flush()
|
||||
while line := sys.stdin.buffer.readline(MAX_MESSAGE_BYTES + 1):
|
||||
if len(line) > MAX_MESSAGE_BYTES or not line.endswith(b"\n"):
|
||||
return
|
||||
sys.stdout.buffer.write(json.dumps(validate_request(line), ensure_ascii=True).encode() + b"\n")
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
546
litellm/proxy/workflows/monty/compiler.py
Normal file
546
litellm/proxy/workflows/monty/compiler.py
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
import ast
|
||||
import hashlib
|
||||
import keyword
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from itertools import chain
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import SchemaError, ValidationError
|
||||
from pydantic import JsonValue
|
||||
from referencing.exceptions import Unresolvable
|
||||
|
||||
from ._validator_worker import closed_registry
|
||||
from .models import CompiledProgram, JsonSchema, MontyErrorKind, MontyFailure, MontyLimits, PythonProgram
|
||||
|
||||
_FORBIDDEN: Final = frozenset(
|
||||
{
|
||||
"eval",
|
||||
"exec",
|
||||
"compile",
|
||||
"open",
|
||||
"__import__",
|
||||
"globals",
|
||||
"locals",
|
||||
"vars",
|
||||
"getattr",
|
||||
"setattr",
|
||||
"delattr",
|
||||
"breakpoint",
|
||||
"input",
|
||||
"help",
|
||||
}
|
||||
)
|
||||
_PREFIX: Final = "_liteflow_"
|
||||
_MAX_SCHEMA_DEPTH: Final = 64
|
||||
_SCHEMA_MAPS: Final = frozenset({"properties", "$defs"})
|
||||
_SCHEMA_VALUES: Final = frozenset({"additionalProperties", "items", "not"})
|
||||
_SCHEMA_LISTS: Final = frozenset({"prefixItems", "anyOf", "oneOf", "allOf"})
|
||||
_SCHEMA_ANNOTATIONS: Final = frozenset(
|
||||
{
|
||||
"const",
|
||||
"enum",
|
||||
"default",
|
||||
"examples",
|
||||
"title",
|
||||
"description",
|
||||
"deprecated",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
"$comment",
|
||||
"format",
|
||||
"x-liteflow-record",
|
||||
"type",
|
||||
"required",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"multipleOf",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
"$ref",
|
||||
}
|
||||
)
|
||||
_GENERATED_GLOBALS: Final = frozenset({"dataclass", "str", "int", "float", "bool", "list", "dict", "all", "isinstance"})
|
||||
_SCHEMA_EXPANSION_BUDGET: Final = 65536
|
||||
|
||||
|
||||
class _SchemaWork:
|
||||
def __init__(self) -> None:
|
||||
self.units = 0
|
||||
self.checked: frozenset[tuple[int, str]] = frozenset()
|
||||
self.keywords: frozenset[tuple[int, str]] = frozenset()
|
||||
self.contains: Mapping[tuple[int, str], bool] = MappingProxyType({})
|
||||
|
||||
def finish_keywords(self, key: tuple[int, str]) -> None:
|
||||
self.keywords = self.keywords | {key}
|
||||
|
||||
def finish_record(self, key: tuple[int, str]) -> None:
|
||||
self.checked = self.checked | {key}
|
||||
|
||||
def finish_contains(self, key: tuple[int, str], value: bool) -> None:
|
||||
self.contains = MappingProxyType(dict(self.contains) | {key: value})
|
||||
|
||||
def spend(self, amount: int = 1) -> None:
|
||||
self.units += amount
|
||||
if self.units > _SCHEMA_EXPANSION_BUDGET:
|
||||
raise ValueError("schema expansion budget exceeded")
|
||||
|
||||
|
||||
def escape_diagnostics(value: str) -> str:
|
||||
return "".join(
|
||||
f"\\x{ord(char):02x}" if (ord(char) < 32 and char not in "\n\t") or 127 <= ord(char) <= 159 else char
|
||||
for char in value
|
||||
)
|
||||
|
||||
|
||||
def _invalid(rule: str, node: ast.AST) -> MontyFailure:
|
||||
return MontyFailure(
|
||||
MontyErrorKind.INVALID_PROGRAM, escape_diagnostics(f"{rule} at line {getattr(node, 'lineno', 1)}")
|
||||
)
|
||||
|
||||
|
||||
def _safe_name(name: str) -> bool:
|
||||
return name.isidentifier() and not keyword.iskeyword(name) and "__" not in name and not name.startswith(_PREFIX)
|
||||
|
||||
|
||||
def _module_statement_allowed(statement: ast.stmt) -> bool:
|
||||
if isinstance(statement, ast.FunctionDef):
|
||||
return True
|
||||
if isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant):
|
||||
return isinstance(statement.value.value, str)
|
||||
return (
|
||||
isinstance(statement, ast.ImportFrom)
|
||||
and statement.module == "__future__"
|
||||
and statement.level == 0
|
||||
and all(alias.name == "annotations" and alias.asname is None for alias in statement.names)
|
||||
)
|
||||
|
||||
|
||||
def _function_error(node: ast.FunctionDef, functions: tuple[ast.FunctionDef, ...]) -> MontyFailure | None:
|
||||
if node not in functions:
|
||||
return _invalid("nested functions and closures are forbidden", node)
|
||||
if node.decorator_list:
|
||||
return _invalid("decorators are forbidden", node)
|
||||
if not _safe_name(node.name) or node.name in _GENERATED_GLOBALS:
|
||||
return _invalid("reserved function name", node)
|
||||
return None
|
||||
|
||||
|
||||
def _node_error(node: ast.AST, tree: ast.Module, functions: tuple[ast.FunctionDef, ...]) -> MontyFailure | None:
|
||||
if isinstance(node, (ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda, ast.Global, ast.Nonlocal)):
|
||||
return _invalid(f"forbidden {type(node).__name__}", node)
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
return _function_error(node, functions)
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)) and node not in tree.body:
|
||||
return _invalid("imports inside functions are forbidden", node)
|
||||
if isinstance(node, ast.Name) and (not _safe_name(node.id) or node.id in _FORBIDDEN):
|
||||
return _invalid("forbidden builtin or reserved name", node)
|
||||
if isinstance(node, ast.arg) and not _safe_name(node.arg):
|
||||
return _invalid("reserved parameter name", node)
|
||||
if isinstance(node, ast.Attribute) and not _safe_name(node.attr):
|
||||
return _invalid("dunder or reserved attribute access", node)
|
||||
return None
|
||||
|
||||
|
||||
def _source_error(tree: ast.Module, program: PythonProgram) -> MontyFailure | None:
|
||||
for statement in tree.body:
|
||||
if not _module_statement_allowed(statement):
|
||||
return _invalid("module body permits only function definitions and future annotations", statement)
|
||||
functions: Final = tuple(node for node in tree.body if isinstance(node, ast.FunctionDef))
|
||||
if len({node.name for node in functions}) != len(functions):
|
||||
return _invalid("duplicate function name", tree)
|
||||
entries: Final = tuple(node for node in functions if node.name == program.function_name)
|
||||
if len(entries) != 1:
|
||||
return _invalid("entry function must exist exactly once", tree)
|
||||
args: Final = entries[0].args
|
||||
if args.posonlyargs or args.vararg or args.kwarg or args.kwonlyargs or args.defaults or args.kw_defaults:
|
||||
return _invalid("entry parameters must be positional without defaults or variadics", entries[0])
|
||||
if tuple(arg.arg for arg in args.args) != program.parameter_names:
|
||||
return _invalid("entry parameter order must match parameter_names", entries[0])
|
||||
return next(
|
||||
(failure for node in ast.walk(tree) if (failure := _node_error(node, tree, functions)) is not None), None
|
||||
)
|
||||
|
||||
|
||||
def _mapping(value: JsonValue) -> JsonSchema:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _resolve(schema: JsonSchema, root: JsonSchema, work: _SchemaWork) -> JsonSchema:
|
||||
work.spend()
|
||||
ref: Final = schema.get("$ref")
|
||||
if not isinstance(ref, str):
|
||||
return schema
|
||||
|
||||
def _monty_schema_pointer(value: JsonValue, parts: tuple[str, ...]) -> JsonSchema:
|
||||
work.spend()
|
||||
if len(parts) > _MAX_SCHEMA_DEPTH:
|
||||
return {}
|
||||
if not parts:
|
||||
return _mapping(value)
|
||||
if isinstance(value, dict):
|
||||
return _monty_schema_pointer(value.get(parts[0].replace("~1", "/").replace("~0", "~")), parts[1:])
|
||||
if isinstance(value, list) and parts[0].isdigit() and int(parts[0]) < len(value):
|
||||
return _monty_schema_pointer(value[int(parts[0])], parts[1:])
|
||||
return {}
|
||||
|
||||
return _monty_schema_pointer(root, tuple(ref[2:].split("/")))
|
||||
|
||||
|
||||
def _monty_reference_exists(root: JsonValue, parts: tuple[str, ...], work: _SchemaWork) -> bool:
|
||||
work.spend()
|
||||
if len(parts) > _MAX_SCHEMA_DEPTH:
|
||||
return False
|
||||
if not parts:
|
||||
return isinstance(root, (dict, bool))
|
||||
part: Final = parts[0].replace("~1", "/").replace("~0", "~")
|
||||
if isinstance(root, dict) and part in root:
|
||||
return _monty_reference_exists(root[part], parts[1:], work)
|
||||
if isinstance(root, list) and part.isdigit() and int(part) < len(root):
|
||||
return _monty_reference_exists(root[int(part)], parts[1:], work)
|
||||
return False
|
||||
|
||||
|
||||
def _monty_schema_error(
|
||||
value: JsonValue, root: JsonSchema, work: _SchemaWork, depth: int = 0, seen: frozenset[str] = frozenset()
|
||||
) -> str | None:
|
||||
work.spend()
|
||||
if depth > _MAX_SCHEMA_DEPTH:
|
||||
return "schema depth limit exceeded"
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
for key, item in value.items():
|
||||
work.spend()
|
||||
if key not in _SCHEMA_MAPS | _SCHEMA_VALUES | _SCHEMA_LISTS | _SCHEMA_ANNOTATIONS:
|
||||
return f"unsupported schema keyword {key}"
|
||||
if key == "$ref" and (not isinstance(item, str) or not item.startswith("#/")):
|
||||
return "only local #/ JSON references are supported"
|
||||
if (
|
||||
key == "$ref"
|
||||
and isinstance(item, str)
|
||||
and not _monty_reference_exists(root, tuple(item[2:].split("/")), work)
|
||||
):
|
||||
return "local JSON reference target does not exist"
|
||||
if (
|
||||
key == "$ref"
|
||||
and isinstance(item, str)
|
||||
and (reference_error := _reference_schema_error(value, root, work, depth, seen, item)) is not None
|
||||
):
|
||||
return reference_error
|
||||
return next(
|
||||
(
|
||||
error
|
||||
for child in _children(value, work)
|
||||
if (error := _monty_schema_error(child, root, work, depth + 1, seen)) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _reference_schema_error(
|
||||
schema: JsonSchema, root: JsonSchema, work: _SchemaWork, depth: int, seen: frozenset[str], ref: str
|
||||
) -> str | None:
|
||||
if ref in seen:
|
||||
return "recursive schema references are unsupported"
|
||||
cache_key: Final = (id(root), ref)
|
||||
if cache_key in work.keywords:
|
||||
return None
|
||||
result: Final = _monty_schema_error(_resolve(schema, root, work), root, work, depth + 1, seen | {ref})
|
||||
if result is None:
|
||||
work.finish_keywords(cache_key)
|
||||
return result
|
||||
|
||||
|
||||
def _record_shape_error(schema: JsonSchema, root: JsonSchema, work: _SchemaWork) -> str | None:
|
||||
if schema.get("x-liteflow-record") is not True:
|
||||
return None
|
||||
if schema not in tuple(record for _, record in _records(root, work)):
|
||||
return "records must be defined in root $defs"
|
||||
if schema.get("type") != "object" or not isinstance(schema.get("properties"), dict):
|
||||
return "records require fixed object properties"
|
||||
if schema.get("additionalProperties") is not False:
|
||||
return "records require additionalProperties false"
|
||||
if any(not _safe_name(name) for name in _mapping(schema.get("properties"))):
|
||||
return "record field names must be safe identifiers"
|
||||
return None
|
||||
|
||||
|
||||
def _record_union_error(schema: JsonSchema, root: JsonSchema, seen: frozenset[str], work: _SchemaWork) -> str | None:
|
||||
for keyword_name in ("anyOf", "oneOf", "allOf"):
|
||||
if not isinstance(alternatives := schema.get(keyword_name), list):
|
||||
continue
|
||||
if not any(_monty_contains_record(_mapping(item), root, seen, work) for item in alternatives):
|
||||
continue
|
||||
if keyword_name != "anyOf" or len(alternatives) != 2:
|
||||
return "records in unions are supported only as Optional X"
|
||||
if sum(_mapping(item).get("type") == "null" for item in alternatives) != 1:
|
||||
return "records in unions are supported only as Optional X"
|
||||
return None
|
||||
|
||||
|
||||
def _monty_record_error(
|
||||
schema: JsonSchema, root: JsonSchema, work: _SchemaWork, seen: frozenset[str] = frozenset(), depth: int = 0
|
||||
) -> str | None:
|
||||
work.spend()
|
||||
if depth > _MAX_SCHEMA_DEPTH:
|
||||
return "schema reference depth limit exceeded"
|
||||
ref: Final = schema.get("$ref")
|
||||
if isinstance(ref, str):
|
||||
if ref in seen:
|
||||
return "recursive schema references are unsupported"
|
||||
cache_key: Final = (id(root), ref)
|
||||
if cache_key in work.checked:
|
||||
return None
|
||||
result: Final = _monty_record_error(_resolve(schema, root, work), root, work, seen | {ref}, depth + 1)
|
||||
if result is None:
|
||||
work.finish_record(cache_key)
|
||||
return result
|
||||
if (shape_error := _record_shape_error(schema, root, work)) is not None:
|
||||
return shape_error
|
||||
if (union_error := _record_union_error(schema, root, seen, work)) is not None:
|
||||
return union_error
|
||||
return next(
|
||||
(
|
||||
error
|
||||
for child in _children(schema, work)
|
||||
if (error := _monty_record_error(child, root, work, seen, depth + 1)) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _monty_contains_record(
|
||||
schema: JsonSchema, root: JsonSchema, seen: frozenset[str], work: _SchemaWork, depth: int = 0
|
||||
) -> bool:
|
||||
work.spend()
|
||||
if depth > _MAX_SCHEMA_DEPTH:
|
||||
return False
|
||||
ref: Final = schema.get("$ref")
|
||||
if isinstance(ref, str):
|
||||
if ref in seen:
|
||||
return False
|
||||
cache_key: Final = (id(root), ref)
|
||||
if cache_key in work.contains:
|
||||
return work.contains[cache_key]
|
||||
result: Final = _monty_contains_record(_resolve(schema, root, work), root, seen | {ref}, work, depth + 1)
|
||||
work.finish_contains(cache_key, result)
|
||||
return result
|
||||
if schema.get("x-liteflow-record") is True:
|
||||
return True
|
||||
return any(_monty_contains_record(child, root, seen, work, depth + 1) for child in _children(schema, work))
|
||||
|
||||
|
||||
def _children(schema: JsonSchema, work: _SchemaWork) -> tuple[JsonSchema, ...]:
|
||||
def children_of(key: str, value: JsonValue) -> tuple[JsonSchema, ...]:
|
||||
work.spend()
|
||||
if key in {"properties", "$defs"} and isinstance(value, dict):
|
||||
work.spend(len(value))
|
||||
return tuple(_mapping(child) for child in value.values())
|
||||
if key in {"anyOf", "oneOf", "allOf", "prefixItems"} and isinstance(value, list):
|
||||
work.spend(len(value))
|
||||
return tuple(_mapping(child) for child in value)
|
||||
if key in {"items", "additionalProperties", "not"} and isinstance(value, dict):
|
||||
return (value,)
|
||||
return ()
|
||||
|
||||
return tuple(chain.from_iterable(children_of(key, value) for key, value in schema.items()))
|
||||
|
||||
|
||||
def _records(root: JsonSchema, work: _SchemaWork) -> tuple[tuple[str, JsonSchema], ...]:
|
||||
work.spend(len(_mapping(root.get("$defs"))))
|
||||
return tuple(
|
||||
(name, value)
|
||||
for name, value in _mapping(root.get("$defs")).items()
|
||||
if isinstance(value, dict) and value.get("x-liteflow-record") is True
|
||||
)
|
||||
|
||||
|
||||
def _monty_build(schema: JsonSchema, root: JsonSchema, expression: str, work: _SchemaWork, depth: int = 0) -> str:
|
||||
work.spend()
|
||||
result: Final = _build_expression(schema, root, expression, work, depth)
|
||||
work.spend(len(result.encode("utf-8")))
|
||||
return result
|
||||
|
||||
|
||||
def _build_expression(schema: JsonSchema, root: JsonSchema, expression: str, work: _SchemaWork, depth: int) -> str:
|
||||
if depth > _MAX_SCHEMA_DEPTH:
|
||||
raise ValueError("schema builder depth limit exceeded")
|
||||
resolved: Final = _resolve(schema, root, work)
|
||||
ref: Final = schema.get("$ref")
|
||||
if isinstance(ref, str) and "$ref" in resolved:
|
||||
return _monty_build(resolved, root, expression, work, depth + 1)
|
||||
union: Final = resolved.get("anyOf")
|
||||
if isinstance(union, list) and _monty_contains_record(resolved, root, frozenset(), work):
|
||||
other: Final = next(_mapping(item) for item in union if _mapping(item).get("type") != "null")
|
||||
return f"(None if {expression} is None else {_monty_build(other, root, expression, work, depth + 1)})"
|
||||
if resolved.get("x-liteflow-record") is True:
|
||||
name: Final = next(name for name, candidate in _records(root, work) if candidate == resolved)
|
||||
required: Final = resolved.get("required", [])
|
||||
|
||||
def field(name: str, child: JsonValue) -> str:
|
||||
access: Final = (
|
||||
f"{expression}[{name!r}]"
|
||||
if isinstance(required, list) and name in required
|
||||
else f"{expression}.get({name!r})"
|
||||
)
|
||||
built: Final = _monty_build(_mapping(child), root, access, work, depth + 1)
|
||||
return (
|
||||
f"{name}={built}"
|
||||
if isinstance(required, list) and name in required
|
||||
else f"{name}=(None if {access} is None else {built})"
|
||||
)
|
||||
|
||||
return (
|
||||
f"{name}("
|
||||
+ ", ".join(field(key, child) for key, child in _mapping(resolved.get("properties")).items())
|
||||
+ ")"
|
||||
)
|
||||
if resolved.get("type") == "array":
|
||||
variable: Final = f"{_PREFIX}item{depth}"
|
||||
return f"[{_monty_build(_mapping(resolved.get('items')), root, variable, work, depth + 1)} for {variable} in {expression}]"
|
||||
if resolved.get("type") == "object" and isinstance(resolved.get("properties"), dict):
|
||||
fields: Final = _mapping(resolved.get("properties"))
|
||||
variable_key: Final = f"{_PREFIX}key{depth}"
|
||||
variable_value: Final = f"{_PREFIX}value{depth}"
|
||||
branches: Final = "".join(
|
||||
f"{_monty_build(_mapping(child), root, variable_value, work, depth + 1)} if {variable_key} == {key!r} else "
|
||||
for key, child in fields.items()
|
||||
)
|
||||
fallback: Final = _monty_build(
|
||||
_mapping(resolved.get("additionalProperties")), root, variable_value, work, depth + 1
|
||||
)
|
||||
return (
|
||||
f"{{{variable_key}: ({branches}{fallback}) for {variable_key}, {variable_value} in {expression}.items()}}"
|
||||
)
|
||||
return expression
|
||||
|
||||
|
||||
def _record_class(name: str, schema: JsonSchema, work: _SchemaWork) -> str:
|
||||
required: Final = schema.get("required", [])
|
||||
fields: Final = _mapping(schema.get("properties"))
|
||||
work.spend(len(fields))
|
||||
mandatory: Final = tuple(key for key in fields if isinstance(required, list) and key in required)
|
||||
optional: Final = tuple(key for key in fields if key not in mandatory)
|
||||
return (
|
||||
f"@dataclass(frozen=True)\nclass {name}:\n"
|
||||
+ (
|
||||
"\n".join(f" {key}: object" for key in mandatory)
|
||||
+ "\n"
|
||||
+ "\n".join(f" {key}: object = None" for key in optional)
|
||||
if fields
|
||||
else " pass"
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def _source(program: PythonProgram, work: _SchemaWork) -> str:
|
||||
records: Final = dict(_records(program.input_schema, work) + _records(program.output_schema, work))
|
||||
classes: Final = "\n".join(_record_class(name, schema, work) for name, schema in records.items())
|
||||
record_dump: Final = "\n".join(
|
||||
f" if isinstance(value, {name}):\n return {_PREFIX}dump({{"
|
||||
+ ", ".join(f"{field!r}: value.{field}" for field in _mapping(schema.get("properties")))
|
||||
+ "})"
|
||||
for name, schema in records.items()
|
||||
)
|
||||
dumper: Final = f"""def {_PREFIX}dump(value):
|
||||
{record_dump}
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return [True, value]
|
||||
if isinstance(value, list):
|
||||
pairs = [{_PREFIX}dump(item) for item in value]
|
||||
return [all(pair[0] for pair in pairs), [pair[1] for pair in pairs]]
|
||||
if isinstance(value, dict):
|
||||
pairs = {{key: {_PREFIX}dump(item) for key, item in value.items()}}
|
||||
return [all(isinstance(key, str) and pair[0] for key, pair in pairs.items()), {{key: pair[1] for key, pair in pairs.items()}}]
|
||||
return [False, None]
|
||||
"""
|
||||
arguments: Final = ", ".join(
|
||||
_monty_build(
|
||||
_mapping(_mapping(program.input_schema.get("properties")).get(name)),
|
||||
program.input_schema,
|
||||
f"{_PREFIX}input[{name!r}]",
|
||||
work,
|
||||
)
|
||||
for name in program.parameter_names
|
||||
)
|
||||
return (
|
||||
"from dataclasses import dataclass\n"
|
||||
+ classes
|
||||
+ "\n"
|
||||
+ program.source
|
||||
+ "\n"
|
||||
+ dumper
|
||||
+ f"\n{_PREFIX}dump({program.function_name}({arguments}))"
|
||||
)
|
||||
|
||||
|
||||
def _schemas_error(program: PythonProgram, tree: ast.Module, work: _SchemaWork) -> MontyFailure | None:
|
||||
function_names: Final = frozenset(node.name for node in tree.body if isinstance(node, ast.FunctionDef))
|
||||
validator: Final = Draft202012Validator(Draft202012Validator.META_SCHEMA, registry=closed_registry())
|
||||
for schema in (program.input_schema, program.output_schema):
|
||||
if (error := _monty_schema_error(schema, schema, work)) is not None:
|
||||
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, escape_diagnostics(error))
|
||||
validator.validate(schema) # pyright: ignore[reportUnknownMemberType] # upstream kwargs are untyped
|
||||
if (record_error := _monty_record_error(schema, schema, work)) is not None:
|
||||
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, escape_diagnostics(record_error))
|
||||
for name, _ in _records(schema, work):
|
||||
if not _safe_name(name) or name in _GENERATED_GLOBALS or name in function_names:
|
||||
return MontyFailure(
|
||||
MontyErrorKind.UNSUPPORTED_SCHEMA, "record class name must be a distinct safe identifier"
|
||||
)
|
||||
input_records: Final = dict(_records(program.input_schema, work))
|
||||
if any(
|
||||
name in input_records and input_records[name] != schema
|
||||
for name, schema in _records(program.output_schema, work)
|
||||
):
|
||||
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, "conflicting record definitions")
|
||||
return _input_schema_error(program)
|
||||
|
||||
|
||||
def _input_schema_error(program: PythonProgram) -> MontyFailure | None:
|
||||
inputs: Final = program.input_schema
|
||||
required: Final = inputs.get("required")
|
||||
if (
|
||||
inputs.get("type") != "object"
|
||||
or set(_mapping(inputs.get("properties"))) != set(program.parameter_names)
|
||||
or inputs.get("additionalProperties") is not False
|
||||
or not isinstance(required, list)
|
||||
or set(required) != set(program.parameter_names)
|
||||
):
|
||||
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, "input schema must require exactly the named parameters")
|
||||
return None
|
||||
|
||||
|
||||
def compile_program(program: PythonProgram, limits: MontyLimits) -> CompiledProgram | MontyFailure:
|
||||
try:
|
||||
encoded: Final = program.source.encode("utf-8")
|
||||
if len(encoded) > limits.max_source_bytes:
|
||||
return MontyFailure(MontyErrorKind.INVALID_PROGRAM, "source byte limit exceeded at line 1")
|
||||
if hashlib.sha256(encoded).hexdigest() != program.source_sha256:
|
||||
return MontyFailure(MontyErrorKind.INVALID_PROGRAM, "source_sha256 mismatch at line 1")
|
||||
if re.search(r"(?m)^\s*#\s*///\s*script\b", program.source):
|
||||
return MontyFailure(MontyErrorKind.INVALID_PROGRAM, "PEP 723 script blocks forbidden at line 1")
|
||||
tree: Final = ast.parse(program.source)
|
||||
if (failure := _source_error(tree, program)) is not None:
|
||||
return failure
|
||||
work: Final = _SchemaWork()
|
||||
if (schema_failure := _schemas_error(program, tree, work)) is not None:
|
||||
return schema_failure
|
||||
generated: Final = _source(program, work)
|
||||
work.spend(len(generated.encode("utf-8")))
|
||||
return CompiledProgram(program, generated, program.input_schema, program.output_schema)
|
||||
except SyntaxError as exc:
|
||||
return MontyFailure(
|
||||
MontyErrorKind.INVALID_PROGRAM, escape_diagnostics(f"syntax error at line {exc.lineno}: {exc.msg}")
|
||||
)
|
||||
except (SchemaError, ValidationError, Unresolvable, ValueError, RecursionError, StopIteration) as exc:
|
||||
return MontyFailure(MontyErrorKind.UNSUPPORTED_SCHEMA, escape_diagnostics(str(exc)))
|
||||
86
litellm/proxy/workflows/monty/models.py
Normal file
86
litellm/proxy/workflows/monty/models.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import keyword
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Literal, TypeAlias
|
||||
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, JsonValue, StringConstraints
|
||||
|
||||
JsonSchema: TypeAlias = dict[str, JsonValue]
|
||||
|
||||
|
||||
def _identifier(value: str) -> str:
|
||||
if not value.isidentifier() or keyword.iskeyword(value):
|
||||
raise ValueError("must be a Python identifier")
|
||||
return value
|
||||
|
||||
|
||||
ProgramName: TypeAlias = Annotated[str, StringConstraints(min_length=1, max_length=128), AfterValidator(_identifier)]
|
||||
|
||||
|
||||
class PythonProgram(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
contract_version: Literal["1"]
|
||||
source: str
|
||||
function_name: ProgramName
|
||||
parameter_names: Annotated[tuple[ProgramName, ...], Field(min_length=1, max_length=32)]
|
||||
input_schema: JsonSchema
|
||||
output_schema: JsonSchema
|
||||
source_sha256: Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{64}$")]
|
||||
|
||||
|
||||
class MontyErrorKind(StrEnum):
|
||||
INVALID_PROGRAM = "INVALID_PROGRAM"
|
||||
UNSUPPORTED_SCHEMA = "UNSUPPORTED_SCHEMA"
|
||||
INPUT_INVALID = "INPUT_INVALID"
|
||||
INPUT_TOO_LARGE = "INPUT_TOO_LARGE"
|
||||
OUTPUT_INVALID = "OUTPUT_INVALID"
|
||||
OUTPUT_TOO_LARGE = "OUTPUT_TOO_LARGE"
|
||||
RUNTIME_ERROR = "RUNTIME_ERROR"
|
||||
TIMEOUT = "TIMEOUT"
|
||||
MEMORY_LIMIT = "MEMORY_LIMIT"
|
||||
RECURSION_LIMIT = "RECURSION_LIMIT"
|
||||
STDOUT_LIMIT = "STDOUT_LIMIT"
|
||||
WORKER_CRASHED = "WORKER_CRASHED"
|
||||
QUEUE_FULL = "QUEUE_FULL"
|
||||
PREDICATE_NOT_BOOL = "PREDICATE_NOT_BOOL"
|
||||
CANCELLED = "CANCELLED"
|
||||
RUNTIME_UNAVAILABLE = "RUNTIME_UNAVAILABLE"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MontyFailure:
|
||||
kind: MontyErrorKind
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MontySuccess:
|
||||
output: JsonValue
|
||||
stdout: str
|
||||
duration_ms: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CompiledProgram:
|
||||
program: PythonProgram
|
||||
sandbox_source: str
|
||||
input_schema: JsonSchema
|
||||
output_schema: JsonSchema
|
||||
|
||||
|
||||
class MontyLimits(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid", allow_inf_nan=False)
|
||||
|
||||
max_source_bytes: int = Field(default=64 * 1024, gt=0, le=1024 * 1024)
|
||||
max_input_bytes: int = Field(default=1024 * 1024, gt=0, le=16 * 1024 * 1024)
|
||||
max_output_bytes: int = Field(default=1024 * 1024, gt=0, le=16 * 1024 * 1024)
|
||||
max_input_depth: int = Field(default=64, gt=0, le=128)
|
||||
max_output_depth: int = Field(default=64, gt=0, le=128)
|
||||
max_memory: int = Field(default=64 * 1024 * 1024, gt=0, le=1024 * 1024 * 1024)
|
||||
worker_address_space_bytes: int = Field(default=512 * 1024 * 1024, gt=0, le=8 * 1024 * 1024 * 1024)
|
||||
feed_seconds: float = Field(default=5, gt=0, le=60)
|
||||
max_recursion_depth: int = Field(default=200, gt=0, le=1000)
|
||||
max_stdout_bytes: int = Field(default=64 * 1024, gt=0, le=1024 * 1024)
|
||||
max_concurrent_workers: int = Field(default=4, gt=0, le=64)
|
||||
max_queued_waiters: int = Field(default=16, gt=0, le=1024)
|
||||
318
litellm/proxy/workflows/monty/runtime.py
Normal file
318
litellm/proxy/workflows/monty/runtime.py
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from .compiler import escape_diagnostics
|
||||
from .models import CompiledProgram, JsonSchema, MontyErrorKind, MontyFailure, MontyLimits, MontySuccess
|
||||
from .validation import ValidatorProcess
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic_monty import AsyncMonty, AsyncMontySession, CollectString, MontyRuntimeError
|
||||
|
||||
_JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BinaryArtifact:
|
||||
path: Path
|
||||
sha256: str
|
||||
|
||||
|
||||
def installed_binary() -> BinaryArtifact | MontyFailure:
|
||||
try:
|
||||
distribution: Final = importlib.metadata.distribution("pydantic-monty-runtime")
|
||||
candidates: Final = tuple(file for file in distribution.files or () if file.name in {"monty", "monty.exe"})
|
||||
if len(candidates) != 1:
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "runtime binary missing or ambiguous")
|
||||
file: Final = candidates[0]
|
||||
if file.hash is None or file.hash.mode != "sha256":
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "runtime binary has no SHA-256 RECORD hash")
|
||||
digest: Final = base64.urlsafe_b64decode(file.hash.value + "=" * (-len(file.hash.value) % 4)).hex()
|
||||
return BinaryArtifact(Path(str(distribution.locate_file(file))).resolve(), digest)
|
||||
except (importlib.metadata.PackageNotFoundError, OSError, ValueError) as exc:
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, escape_diagnostics(str(exc)))
|
||||
|
||||
|
||||
def _monty_json_issue(value: JsonValue, depth: int, max_depth: int) -> MontyErrorKind | None:
|
||||
if depth > max_depth:
|
||||
return MontyErrorKind.INPUT_TOO_LARGE
|
||||
if value is None or type(value) in (str, int, bool):
|
||||
return None
|
||||
if type(value) is float:
|
||||
return None if math.isfinite(value) else MontyErrorKind.INPUT_INVALID
|
||||
if type(value) is list:
|
||||
return next(
|
||||
(issue for item in value if (issue := _monty_json_issue(item, depth + 1, max_depth)) is not None), None
|
||||
)
|
||||
if type(value) is dict:
|
||||
if any(type(key) is not str for key in value):
|
||||
return MontyErrorKind.INPUT_INVALID
|
||||
return next(
|
||||
(
|
||||
child_issue
|
||||
for item in value.values()
|
||||
if (child_issue := _monty_json_issue(item, depth + 1, max_depth)) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
return MontyErrorKind.INPUT_INVALID
|
||||
|
||||
|
||||
def validate_json(value: object, *, max_bytes: int, max_depth: int, output: bool = False) -> JsonValue | MontyFailure:
|
||||
invalid: Final = MontyErrorKind.OUTPUT_INVALID if output else MontyErrorKind.INPUT_INVALID
|
||||
too_large: Final = MontyErrorKind.OUTPUT_TOO_LARGE if output else MontyErrorKind.INPUT_TOO_LARGE
|
||||
try:
|
||||
parsed: Final = _JSON.validate_python(value, strict=True)
|
||||
issue: Final = _monty_json_issue(parsed, 0, max_depth)
|
||||
if issue is not None:
|
||||
return MontyFailure(
|
||||
too_large if issue == MontyErrorKind.INPUT_TOO_LARGE else invalid, "JSON depth or type invalid"
|
||||
)
|
||||
encoded: Final = json.dumps(parsed, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) > max_bytes:
|
||||
return MontyFailure(too_large, "JSON byte limit exceeded")
|
||||
except PydanticValidationError as exc:
|
||||
if '"type":"recursion_loop"' in exc.json():
|
||||
return MontyFailure(too_large, "JSON depth limit exceeded")
|
||||
return MontyFailure(invalid, escape_diagnostics(str(exc))[:4096])
|
||||
except (ValueError, OverflowError, RecursionError) as exc:
|
||||
return MontyFailure(invalid, escape_diagnostics(str(exc))[:4096])
|
||||
else:
|
||||
return parsed
|
||||
|
||||
|
||||
def _kill(session: AsyncMontySession) -> None:
|
||||
pid: Final = session.worker_pid
|
||||
if pid is None:
|
||||
return
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
|
||||
def _worker_limit(session: AsyncMontySession, limits: MontyLimits) -> MontyFailure | None:
|
||||
if sys.platform != "linux":
|
||||
return None
|
||||
import resource
|
||||
|
||||
pid: Final = session.worker_pid
|
||||
if pid is None:
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "worker PID unavailable")
|
||||
try:
|
||||
resource.prlimit(
|
||||
pid, resource.RLIMIT_AS, (limits.worker_address_space_bytes, limits.worker_address_space_bytes)
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, escape_diagnostics(str(exc)))
|
||||
return None
|
||||
|
||||
|
||||
def _runtime_failure(exc: MontyRuntimeError, collector: CollectString, limits: MontyLimits) -> MontyFailure:
|
||||
error: Final = exc.exception()
|
||||
message: Final = escape_diagnostics(exc.display())[:4096]
|
||||
if isinstance(error, TimeoutError):
|
||||
return MontyFailure(MontyErrorKind.TIMEOUT, message)
|
||||
if isinstance(error, MemoryError):
|
||||
kind: Final = (
|
||||
MontyErrorKind.STDOUT_LIMIT
|
||||
if len(collector.output.encode("utf-8")) >= limits.max_stdout_bytes and not exc.traceback()
|
||||
else MontyErrorKind.MEMORY_LIMIT
|
||||
)
|
||||
return MontyFailure(kind, message)
|
||||
if isinstance(error, RecursionError):
|
||||
return MontyFailure(MontyErrorKind.RECURSION_LIMIT, message)
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_ERROR, message)
|
||||
|
||||
|
||||
class MontyRuntime:
|
||||
def __init__(self, limits: MontyLimits, *, binary: BinaryArtifact | None = None) -> None:
|
||||
self.limits = limits
|
||||
self._binary = binary
|
||||
self._pool: AsyncMonty | None = None
|
||||
self._slots = asyncio.Semaphore(limits.max_concurrent_workers)
|
||||
self._admitted = 0
|
||||
self._validators = tuple(ValidatorProcess() for _ in range(limits.max_concurrent_workers))
|
||||
|
||||
async def start(self) -> MontyFailure | None:
|
||||
if self._pool is not None:
|
||||
return None
|
||||
try:
|
||||
from pydantic_monty import AsyncMonty
|
||||
|
||||
artifact: Final = self._binary if self._binary is not None else installed_binary()
|
||||
if isinstance(artifact, MontyFailure):
|
||||
return artifact
|
||||
if hashlib.sha256(artifact.path.read_bytes()).hexdigest() != artifact.sha256:
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "runtime binary SHA-256 mismatch")
|
||||
pool: Final = AsyncMonty(
|
||||
binary_path=artifact.path,
|
||||
min_processes=0,
|
||||
max_processes=self.limits.max_concurrent_workers,
|
||||
max_checkouts_per_worker=1,
|
||||
checkout_timeout=self.limits.feed_seconds,
|
||||
request_timeout=self.limits.feed_seconds + 1,
|
||||
feed_duration_limit_grace=0.1,
|
||||
)
|
||||
await pool.__aenter__()
|
||||
validators: Final = await asyncio.gather(*(worker.start() for worker in self._validators))
|
||||
if (failure := next((result for result in validators if result is not None), None)) is not None:
|
||||
await asyncio.gather(*(worker.close() for worker in self._validators))
|
||||
await pool.__aexit__(None, None, None)
|
||||
return failure
|
||||
self._pool = pool
|
||||
except (ImportError, OSError, ValueError, RuntimeError) as exc:
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, escape_diagnostics(str(exc)))
|
||||
else:
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
pool: Final = self._pool
|
||||
self._pool = None
|
||||
await asyncio.gather(*(worker.close() for worker in self._validators))
|
||||
if pool is not None:
|
||||
await pool.__aexit__(None, None, None)
|
||||
|
||||
async def run_program(
|
||||
self, compiled: CompiledProgram, input: object, *, deadline_monotonic: float
|
||||
) -> MontySuccess | MontyFailure:
|
||||
started: Final = time.monotonic()
|
||||
if started >= deadline_monotonic:
|
||||
return MontyFailure(MontyErrorKind.TIMEOUT, "node deadline expired")
|
||||
if self._pool is None:
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "Monty runtime is not started")
|
||||
if self._admitted >= self.limits.max_concurrent_workers + self.limits.max_queued_waiters:
|
||||
return MontyFailure(MontyErrorKind.QUEUE_FULL, "Monty execution queue is full")
|
||||
self._admitted += 1
|
||||
try:
|
||||
async with asyncio.timeout_at(asyncio.get_running_loop().time() + deadline_monotonic - time.monotonic()):
|
||||
async with self._slots:
|
||||
worker: Final = next(worker for worker in self._validators if not worker.busy)
|
||||
worker.busy = True
|
||||
try:
|
||||
validated: Final = await self._validate(
|
||||
input, compiled.input_schema, worker, deadline_monotonic
|
||||
)
|
||||
if isinstance(validated, MontyFailure):
|
||||
return validated
|
||||
return await self._execute(compiled, validated, deadline_monotonic, started, worker)
|
||||
except asyncio.CancelledError:
|
||||
await worker.close()
|
||||
raise
|
||||
finally:
|
||||
worker.busy = False
|
||||
except TimeoutError:
|
||||
return MontyFailure(MontyErrorKind.TIMEOUT, "node deadline expired")
|
||||
finally:
|
||||
self._admitted -= 1
|
||||
|
||||
async def _execute(
|
||||
self, compiled: CompiledProgram, input: JsonValue, deadline: float, started: float, worker: ValidatorProcess
|
||||
) -> MontySuccess | MontyFailure:
|
||||
from pydantic_monty import CollectString, MontyCrashedError, MontyRuntimeError, MontySyntaxError, ResourceLimits
|
||||
|
||||
pool: Final = self._pool
|
||||
if pool is None:
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "Monty runtime is closed")
|
||||
remaining: Final = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return MontyFailure(MontyErrorKind.TIMEOUT, "node deadline expired")
|
||||
collector: Final = CollectString(max_bytes=self.limits.max_stdout_bytes)
|
||||
session: Final = pool.checkout(
|
||||
script_name="workflow.py",
|
||||
limits=ResourceLimits(
|
||||
max_feed_duration_secs=min(self.limits.feed_seconds, remaining),
|
||||
max_memory=self.limits.max_memory,
|
||||
max_recursion_depth=self.limits.max_recursion_depth,
|
||||
),
|
||||
os_policy={"sleep": "zero"},
|
||||
)
|
||||
try:
|
||||
async with session:
|
||||
try:
|
||||
if (limit_failure := _worker_limit(session, self.limits)) is not None:
|
||||
return limit_failure
|
||||
result: Final = _JSON.validate_python(
|
||||
await session.feed_run(
|
||||
compiled.sandbox_source,
|
||||
inputs={"_liteflow_input": input},
|
||||
print_callback=collector,
|
||||
),
|
||||
strict=True,
|
||||
)
|
||||
return await self._output(result, compiled, collector.output, deadline, started, worker)
|
||||
except asyncio.CancelledError:
|
||||
_kill(session)
|
||||
raise
|
||||
except MontyRuntimeError as exc:
|
||||
return _runtime_failure(exc, collector, self.limits)
|
||||
except MontyCrashedError as exc:
|
||||
return MontyFailure(
|
||||
MontyErrorKind.TIMEOUT if exc.timed_out else MontyErrorKind.WORKER_CRASHED,
|
||||
escape_diagnostics(str(exc))[:4096],
|
||||
)
|
||||
except MontySyntaxError as exc:
|
||||
return MontyFailure(MontyErrorKind.INVALID_PROGRAM, escape_diagnostics(str(exc))[:4096])
|
||||
except (ValueError, OSError) as exc:
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_ERROR, escape_diagnostics(str(exc))[:4096])
|
||||
|
||||
async def _output(
|
||||
self,
|
||||
result: object,
|
||||
compiled: CompiledProgram,
|
||||
stdout: str,
|
||||
deadline: float,
|
||||
started: float,
|
||||
worker: ValidatorProcess,
|
||||
) -> MontySuccess | MontyFailure:
|
||||
try:
|
||||
envelope: Final = _JSON.validate_python(result, strict=True)
|
||||
except ValueError:
|
||||
return MontyFailure(MontyErrorKind.OUTPUT_INVALID, "return value is not strict JSON")
|
||||
if not isinstance(envelope, list) or len(envelope) != 2 or envelope[0] is not True:
|
||||
return MontyFailure(MontyErrorKind.OUTPUT_INVALID, "return value is not strict JSON")
|
||||
output: Final = await self._validate(envelope[1], compiled.output_schema, worker, deadline, output=True)
|
||||
if isinstance(output, MontyFailure):
|
||||
return output
|
||||
if time.monotonic() >= deadline:
|
||||
return MontyFailure(MontyErrorKind.TIMEOUT, "node deadline expired during validation")
|
||||
return MontySuccess(output, escape_diagnostics(stdout), int((time.monotonic() - started) * 1000))
|
||||
|
||||
async def _validate(
|
||||
self, value: object, schema: JsonSchema, worker: ValidatorProcess, deadline: float, *, output: bool = False
|
||||
) -> JsonValue | MontyFailure:
|
||||
parsed: Final = validate_json(
|
||||
value,
|
||||
max_bytes=self.limits.max_output_bytes if output else self.limits.max_input_bytes,
|
||||
max_depth=self.limits.max_output_depth if output else self.limits.max_input_depth,
|
||||
output=output,
|
||||
)
|
||||
if isinstance(parsed, MontyFailure):
|
||||
return parsed
|
||||
failure: Final = await worker.validate(parsed, schema, deadline=deadline, output=output)
|
||||
return failure if failure is not None else parsed
|
||||
|
||||
async def evaluate_predicate(
|
||||
self, compiled: CompiledProgram, input: object, *, deadline_monotonic: float
|
||||
) -> bool | MontyFailure:
|
||||
result: Final = await self.run_program(compiled, input, deadline_monotonic=deadline_monotonic)
|
||||
if isinstance(result, MontyFailure):
|
||||
return result
|
||||
if type(result.output) is not bool:
|
||||
return MontyFailure(MontyErrorKind.PREDICATE_NOT_BOOL, "predicate must return exactly bool")
|
||||
return result.output
|
||||
98
litellm/proxy/workflows/monty/validation.py
Normal file
98
litellm/proxy/workflows/monty/validation.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, StrictBool, StrictStr
|
||||
|
||||
from ._validator_worker import MAX_MESSAGE_BYTES
|
||||
from .compiler import escape_diagnostics
|
||||
from .models import JsonSchema, MontyErrorKind, MontyFailure
|
||||
|
||||
|
||||
class _Response(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
valid: StrictBool
|
||||
message: StrictStr
|
||||
|
||||
|
||||
class ValidatorProcess:
|
||||
def __init__(self) -> None:
|
||||
self.busy = False
|
||||
self.process: asyncio.subprocess.Process | None = None
|
||||
|
||||
async def start(self) -> MontyFailure | None:
|
||||
if self.process is not None and self.process.returncode is None:
|
||||
return None
|
||||
try:
|
||||
async with asyncio.timeout(5):
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-I",
|
||||
str(Path(__file__).with_name("_validator_worker.py")),
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
env={},
|
||||
limit=65536,
|
||||
)
|
||||
if self.process.stdout is None or await self.process.stdout.readline() != b"ready\n":
|
||||
await self.close()
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, "validator startup failed")
|
||||
except asyncio.CancelledError:
|
||||
await self.close()
|
||||
raise
|
||||
except (OSError, ValueError, TimeoutError) as exc:
|
||||
await self.close()
|
||||
return MontyFailure(MontyErrorKind.RUNTIME_UNAVAILABLE, escape_diagnostics(str(exc)))
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
process: Final = self.process
|
||||
self.process = None
|
||||
if process is not None:
|
||||
if process.returncode is None:
|
||||
try:
|
||||
process.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
await process.wait()
|
||||
|
||||
async def validate(
|
||||
self, value: JsonValue, schema: JsonSchema, *, deadline: float, output: bool = False
|
||||
) -> MontyFailure | None:
|
||||
try:
|
||||
async with asyncio.timeout(max(0, deadline - time.monotonic())):
|
||||
if (failure := await self.start()) is not None:
|
||||
return failure
|
||||
process: Final = self.process
|
||||
if process is None or process.stdin is None or process.stdout is None:
|
||||
return MontyFailure(MontyErrorKind.WORKER_CRASHED, "validator pipes unavailable")
|
||||
request: Final = json.dumps({"value": value, "schema": schema}, ensure_ascii=True).encode() + b"\n"
|
||||
if len(request) > MAX_MESSAGE_BYTES:
|
||||
return MontyFailure(
|
||||
MontyErrorKind.OUTPUT_TOO_LARGE if output else MontyErrorKind.INPUT_TOO_LARGE,
|
||||
"validator request byte limit exceeded",
|
||||
)
|
||||
process.stdin.write(request)
|
||||
await process.stdin.drain()
|
||||
response: Final = _Response.model_validate_json(await process.stdout.readline())
|
||||
if not response.valid:
|
||||
return MontyFailure(
|
||||
MontyErrorKind.OUTPUT_INVALID if output else MontyErrorKind.INPUT_INVALID,
|
||||
escape_diagnostics(response.message)[:4096],
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await self.close()
|
||||
raise
|
||||
except TimeoutError:
|
||||
await self.close()
|
||||
return MontyFailure(MontyErrorKind.TIMEOUT, "node deadline expired during schema validation")
|
||||
except (OSError, ValueError, RecursionError) as exc:
|
||||
await self.close()
|
||||
return MontyFailure(MontyErrorKind.WORKER_CRASHED, escape_diagnostics(str(exc))[:4096])
|
||||
return None
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ directory that a `GROUPS` entry in `run.py` selects. There is no manifest and no
|
|||
tests. A product bug the test exposes is `pytest.skip("BUG: <symptom>")` at the top of the body, not a
|
||||
fix in the test and not a deletion. Needs no network, proxy, DB or Redis: `tests/unit`
|
||||
|
||||
CircleCI's integration groups run every file here except the OCI and sandbox tests, which no CI runs, and
|
||||
CircleCI's integration groups run every file here except the OCI tests, which no CI runs, and
|
||||
the files in `GITHUB_FILES` in `run.py`. `test-integration.yml` runs the same groups through the same
|
||||
`run_integration.sh` on GitHub Actions, on main every three hours from 8am to 8pm Pacific Monday to Saturday,
|
||||
and on a pull request when someone adds the `run-ci` label. Neither runs every pull request, so a new file here
|
||||
|
|
@ -41,6 +41,11 @@ together with its dedicated job, and list a new dedicated GitHub workflow with t
|
|||
fails a file here that a GitHub workflow runs unless it is in `GITHUB_FILES` and that workflow owns its
|
||||
directory (#46780)
|
||||
|
||||
The Monty sandbox tests are an explicit exception. `test-integration.yml` owns `sandbox/` and runs
|
||||
`uv run --no-sync pytest tests/integration/sandbox/test_monty_runtime.py -q` in its focused Monty job
|
||||
on pull requests, scheduled runs and manual `all` or `monty` runs. It installs the proxy extra and
|
||||
needs no gateway, database, Redis or credentials. Keep subprocess and timing tests here, not in unit tests
|
||||
|
||||
## Fixtures that boot a proxy
|
||||
|
||||
Scope them to the module or class, or pin a session one to a single worker with an `xdist_group` mark
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ These tests exercise a running gateway, PostgreSQL and Redis with an owned local
|
|||
|
||||
The ROI database contracts in `database/test_roi_observed.py` run as plain pytest outside `run_integration.sh` in CircleCI's `roi-database` Postgres job. The job uploads coverage with the `roi-postgres` flag. These contracts own temporary databases and script only the external provider transport
|
||||
|
||||
The `Monty sandbox` job in `test-integration.yml` runs `uv run --no-sync pytest tests/integration/sandbox/test_monty_runtime.py -q` with the proxy extra on pull requests, schedules and manual `all` or `monty` runs. It exercises real Monty and isolated JSON Schema validator processes without external services. Sandbox tests are outside the CircleCI groups
|
||||
|
||||
The Redis cluster IAM contracts in `sdk/test_redis_cluster_iam_auth.py` start their own `redis-server` and mock the cloud credential clients, so CircleCI's `sdk` group installs `redis-server` before it runs them
|
||||
|
||||
The `cost` group is driven by the `CostTrackingTestCase` literals under `cost_calculation/cases/<provider>.py` (gathered by `cost_calculation/catalog.py`), with the scripted cost map in `cost_calculation/cost_map.py`. Each case has a name, contract ID, cost-map model, optional deployment overrides, request body, tagged response and exact or recount expectations. Request bodies use `$MODEL` for the registered proxy model, while responses use `$REQUEST_ID` for the per-run scenario ID. To add a case, add a cost-map entry when the model is new, add the request body and exact provider response data, and add hand-computed expected values. The upstream serves each stored response for any path under `/<scenario_id>`, while the test-owned cost map is served over loopback through `LITELLM_MODEL_COST_MAP_URL`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
614
tests/integration/sandbox/test_monty_runtime.py
Normal file
614
tests/integration/sandbox/test_monty_runtime.py
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from openai.types.chat import ChatCompletionMessageParam
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter
|
||||
|
||||
from litellm.proxy.workflows.monty.compiler import compile_program
|
||||
from litellm.proxy.workflows.monty.models import (
|
||||
CompiledProgram,
|
||||
JsonSchema,
|
||||
MontyErrorKind,
|
||||
MontyFailure,
|
||||
MontyLimits,
|
||||
MontySuccess,
|
||||
PythonProgram,
|
||||
)
|
||||
from litellm.proxy.workflows.monty.runtime import BinaryArtifact, MontyRuntime, installed_binary
|
||||
from tests.integration._support.process import stop_processes
|
||||
|
||||
pytest.importorskip("pydantic_monty")
|
||||
|
||||
|
||||
def _compile(
|
||||
source: str,
|
||||
output: JsonSchema | None = None,
|
||||
argument: JsonSchema | None = None,
|
||||
definitions: JsonSchema | None = None,
|
||||
) -> CompiledProgram:
|
||||
program: Final = PythonProgram(
|
||||
contract_version="1",
|
||||
source=source,
|
||||
function_name="transform",
|
||||
parameter_names=("value",),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"value": argument if argument is not None else {}},
|
||||
"required": ["value"],
|
||||
"additionalProperties": False,
|
||||
"$defs": definitions or {},
|
||||
},
|
||||
output_schema={**(output or {}), "$defs": definitions or {}},
|
||||
source_sha256=hashlib.sha256(source.encode()).hexdigest(),
|
||||
)
|
||||
result: Final = compile_program(program, MontyLimits())
|
||||
assert isinstance(result, CompiledProgram), result
|
||||
return result
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="function")
|
||||
async def runtime() -> AsyncIterator[MontyRuntime]:
|
||||
instance: Final = MontyRuntime(MontyLimits(max_concurrent_workers=1, max_queued_waiters=1))
|
||||
assert await instance.start() is None
|
||||
try:
|
||||
yield instance
|
||||
finally:
|
||||
await instance.close()
|
||||
|
||||
|
||||
async def _run(runtime: MontyRuntime, compiled: CompiledProgram, value: object) -> MontySuccess | MontyFailure:
|
||||
return await runtime.run_program(compiled, {"value": value}, deadline_monotonic=time.monotonic() + 10)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
{"__class__": "os.system"},
|
||||
{"type": "function", "callable": "os.system"},
|
||||
"Bearer sk-test-fake",
|
||||
"${OPENAI_API_KEY}",
|
||||
"https://example.invalid/action",
|
||||
[],
|
||||
None,
|
||||
],
|
||||
)
|
||||
async def test_model_output_remains_inert_json(runtime: MontyRuntime, value: object) -> None:
|
||||
result: Final = await _run(runtime, _compile("def transform(value): return value"), value)
|
||||
assert isinstance(result, MontySuccess), result
|
||||
assert result.output == value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typed_json_transform_and_escaped_stdout(runtime: MontyRuntime) -> None:
|
||||
compiled: Final = _compile(
|
||||
"def transform(value):\n print('\\x1b[31m')\n return {'n': value['n'] + 1}",
|
||||
{"type": "object", "properties": {"n": {"type": "integer"}}, "required": ["n"]},
|
||||
)
|
||||
result: Final = await _run(runtime, compiled, {"n": 2})
|
||||
assert isinstance(result, MontySuccess), result
|
||||
assert result.output == {"n": 3}
|
||||
assert result.stdout == "\\x1b[31m\n"
|
||||
|
||||
|
||||
_RECORDS: Final[JsonSchema] = {
|
||||
"Expense": {
|
||||
"type": "object",
|
||||
"x-liteflow-record": True,
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"amount_cents": {"type": "integer", "minimum": 0},
|
||||
"currency": {"type": "string"},
|
||||
"receipt_present": {"type": "boolean"},
|
||||
},
|
||||
"required": ["amount_cents", "currency", "receipt_present"],
|
||||
},
|
||||
}
|
||||
_EXPENSE: Final[JsonSchema] = {"$ref": "#/$defs/Expense"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("receipt,amount,review", [(True, 100, False), (False, 100, True), (True, 10001, True)])
|
||||
async def test_expense_normalize_condition_and_both_branches(
|
||||
runtime: MontyRuntime, receipt: bool, amount: int, review: bool
|
||||
) -> None:
|
||||
normalize: Final = _compile(
|
||||
"def transform(value: Expense) -> Expense:\n return Expense(amount_cents=value.amount_cents, currency=value.currency.strip().upper(), receipt_present=value.receipt_present)",
|
||||
_EXPENSE,
|
||||
_EXPENSE,
|
||||
_RECORDS,
|
||||
)
|
||||
normalized: Final = await _run(
|
||||
runtime, normalize, {"amount_cents": amount, "currency": " usd ", "receipt_present": receipt}
|
||||
)
|
||||
assert isinstance(normalized, MontySuccess), normalized
|
||||
assert normalized.output == {"amount_cents": amount, "currency": "USD", "receipt_present": receipt}
|
||||
condition: Final = _compile(
|
||||
"def transform(value: Expense) -> bool:\n return not value.receipt_present or value.currency != 'USD' or value.amount_cents > 10000",
|
||||
{"type": "boolean"},
|
||||
_EXPENSE,
|
||||
_RECORDS,
|
||||
)
|
||||
assert (
|
||||
await runtime.evaluate_predicate(
|
||||
condition, {"value": normalized.output}, deadline_monotonic=time.monotonic() + 10
|
||||
)
|
||||
is review
|
||||
)
|
||||
status: Final = "review_required" if review else "ready_for_processing"
|
||||
branch: Final = _compile(
|
||||
f"def transform(value): return {{'status': {status!r}, 'amount_cents': value.amount_cents, 'currency': value.currency}}",
|
||||
{"type": "object"},
|
||||
_EXPENSE,
|
||||
_RECORDS,
|
||||
)
|
||||
result: Final = await _run(runtime, branch, normalized.output)
|
||||
assert isinstance(result, MontySuccess), result
|
||||
assert result.output == {"status": status, "amount_cents": amount, "currency": "USD"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[
|
||||
"def transform(value): return Expense(amount_cents=1, currency='USD', receipt_present=True)",
|
||||
"def transform(value): return {'amount_cents':1, 'currency':'USD', 'receipt_present':True}",
|
||||
],
|
||||
)
|
||||
async def test_record_and_equal_dict_are_equivalent(runtime: MontyRuntime, source: str) -> None:
|
||||
result: Final = await _run(runtime, _compile(source, _EXPENSE, definitions=_RECORDS), None)
|
||||
assert isinstance(result, MontySuccess), result
|
||||
assert result.output == {"amount_cents": 1, "currency": "USD", "receipt_present": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("value", [None, {"amount_cents": 7, "currency": "USD", "receipt_present": True}])
|
||||
async def test_optional_record(runtime: MontyRuntime, value: object) -> None:
|
||||
optional: Final[JsonSchema] = {"anyOf": [_EXPENSE, {"type": "null"}]}
|
||||
result: Final = await _run(
|
||||
runtime,
|
||||
_compile(
|
||||
"def transform(value): return None if value is None else value.amount_cents",
|
||||
argument=optional,
|
||||
definitions=_RECORDS,
|
||||
),
|
||||
value,
|
||||
)
|
||||
assert isinstance(result, MontySuccess), result
|
||||
assert result.output == (None if value is None else 7)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_of_records(runtime: MontyRuntime) -> None:
|
||||
result: Final = await _run(
|
||||
runtime,
|
||||
_compile(
|
||||
"def transform(value): return [item.currency for item in value]",
|
||||
argument={"type": "array", "items": _EXPENSE},
|
||||
definitions=_RECORDS,
|
||||
),
|
||||
[{"amount_cents": 7, "currency": "USD", "receipt_present": True}],
|
||||
)
|
||||
assert isinstance(result, MontySuccess), result
|
||||
assert result.output == ["USD"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("value", [1, "true", None])
|
||||
async def test_predicate_requires_exact_bool(runtime: MontyRuntime, value: object) -> None:
|
||||
result: Final = await runtime.evaluate_predicate(
|
||||
_compile("def transform(value): return value"), {"value": value}, deadline_monotonic=time.monotonic() + 10
|
||||
)
|
||||
assert isinstance(result, MontyFailure)
|
||||
assert result.kind == MontyErrorKind.PREDICATE_NOT_BOOL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"source,schema,kind",
|
||||
[
|
||||
("def transform(value): return -1", {"type": "integer", "minimum": 0}, MontyErrorKind.OUTPUT_INVALID),
|
||||
("def transform(value): return 'wrong'", {"type": "integer"}, MontyErrorKind.OUTPUT_INVALID),
|
||||
("def helper(x): return x\ndef transform(value): return helper", {}, MontyErrorKind.OUTPUT_INVALID),
|
||||
("def transform(value): return 'x' * 1100000", {}, MontyErrorKind.OUTPUT_TOO_LARGE),
|
||||
("def transform(value):\n while True: pass", {}, MontyErrorKind.TIMEOUT),
|
||||
("def transform(value): return [0] * 1000000000", {}, MontyErrorKind.MEMORY_LIMIT),
|
||||
("def transform(value): return transform(value)", {}, MontyErrorKind.RECURSION_LIMIT),
|
||||
("def transform(value): print('x' * 100000)", {}, MontyErrorKind.STDOUT_LIMIT),
|
||||
("def transform(value): return 1 / 0", {}, MontyErrorKind.RUNTIME_ERROR),
|
||||
],
|
||||
)
|
||||
async def test_resource_and_output_failures(
|
||||
runtime: MontyRuntime, source: str, schema: JsonSchema, kind: MontyErrorKind
|
||||
) -> None:
|
||||
result: Final = await _run(runtime, _compile(source, schema), None)
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == kind, result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"value,kind",
|
||||
[
|
||||
({1: "not JSON"}, MontyErrorKind.INPUT_INVALID),
|
||||
(float("nan"), MontyErrorKind.INPUT_INVALID),
|
||||
("x" * 1100000, MontyErrorKind.INPUT_TOO_LARGE),
|
||||
],
|
||||
)
|
||||
async def test_input_failures(runtime: MontyRuntime, value: object, kind: MontyErrorKind) -> None:
|
||||
result: Final = await _run(runtime, _compile("def transform(value): return value"), value)
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == kind
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_deadline(runtime: MontyRuntime) -> None:
|
||||
result: Final = await runtime.run_program(
|
||||
_compile("def transform(value): return value"), {"value": 1}, deadline_monotonic=0
|
||||
)
|
||||
assert isinstance(result, MontyFailure)
|
||||
assert result.kind == MontyErrorKind.TIMEOUT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_binary_digest_mismatch_fails_closed() -> None:
|
||||
artifact: Final = installed_binary()
|
||||
assert isinstance(artifact, BinaryArtifact)
|
||||
runtime: Final = MontyRuntime(MontyLimits(), binary=BinaryArtifact(artifact.path, "0" * 64))
|
||||
result: Final = await runtime.start()
|
||||
assert isinstance(result, MontyFailure)
|
||||
assert result.kind == MontyErrorKind.RUNTIME_UNAVAILABLE
|
||||
assert "SHA-256" in result.message
|
||||
|
||||
|
||||
class Finding(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
summary: str
|
||||
|
||||
|
||||
class EmailAnalyses(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
urgency: Finding
|
||||
intent: Finding
|
||||
sentiment: Finding
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_argument_email_draft_preserves_chat_messages(runtime: MontyRuntime) -> None:
|
||||
analyses_schema: Final = EmailAnalyses.model_json_schema()
|
||||
messages_schema: Final = TypeAdapter(list[ChatCompletionMessageParam]).json_schema()
|
||||
definitions: Final[JsonSchema] = {
|
||||
**messages_schema.pop("$defs", {}),
|
||||
"Finding": {**analyses_schema["$defs"]["Finding"], "x-liteflow-record": True, "additionalProperties": False},
|
||||
"EmailAnalyses": {
|
||||
**{key: value for key, value in analyses_schema.items() if key != "$defs"},
|
||||
"x-liteflow-record": True,
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
source: Final = """def prepare_draft(analyses: EmailAnalyses, messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
instruction = (
|
||||
"Draft a customer-service reply using the original messages. "
|
||||
"Treat these analyses as fallible guidance, not additional facts.\\n"
|
||||
f"Urgency: {analyses.urgency.summary}\\n"
|
||||
f"Intent: {analyses.intent.summary}\\n"
|
||||
f"Sentiment: {analyses.sentiment.summary}"
|
||||
)
|
||||
return [{"role": "system", "content": instruction}] + messages
|
||||
"""
|
||||
program: Final = PythonProgram(
|
||||
contract_version="1",
|
||||
source=source,
|
||||
function_name="prepare_draft",
|
||||
parameter_names=("analyses", "messages"),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"analyses": {"$ref": "#/$defs/EmailAnalyses"}, "messages": messages_schema},
|
||||
"required": ["analyses", "messages"],
|
||||
"additionalProperties": False,
|
||||
"$defs": definitions,
|
||||
},
|
||||
output_schema={**messages_schema, "$defs": definitions},
|
||||
source_sha256=hashlib.sha256(source.encode()).hexdigest(),
|
||||
)
|
||||
compiled: Final = compile_program(program, MontyLimits())
|
||||
assert isinstance(compiled, CompiledProgram), compiled
|
||||
messages: Final = [{"role": "user", "content": "Please reply", "name": "requester"}]
|
||||
result: Final = await runtime.run_program(
|
||||
compiled,
|
||||
{
|
||||
"analyses": {
|
||||
"urgency": {"summary": "soon"},
|
||||
"intent": {"summary": "reply"},
|
||||
"sentiment": {"summary": "neutral"},
|
||||
},
|
||||
"messages": messages,
|
||||
},
|
||||
deadline_monotonic=time.monotonic() + 10,
|
||||
)
|
||||
assert isinstance(result, MontySuccess), result
|
||||
assert result.output == [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Draft a customer-service reply using the original messages. "
|
||||
"Treat these analyses as fallible guidance, not additional facts.\n"
|
||||
"Urgency: soon\nIntent: reply\nSentiment: neutral",
|
||||
},
|
||||
*messages,
|
||||
]
|
||||
|
||||
|
||||
def _nested(depth: int) -> object:
|
||||
return [_nested(depth - 1)] if depth else 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_too_deep_input_and_output_are_rejected(runtime: MontyRuntime) -> None:
|
||||
input_result: Final = await _run(runtime, _compile("def transform(value): return value"), _nested(66))
|
||||
assert isinstance(input_result, MontyFailure), input_result
|
||||
assert input_result.kind == MontyErrorKind.INPUT_TOO_LARGE
|
||||
output_result: Final = await _run(
|
||||
runtime, _compile("def transform(value):\n for i in range(66): value = [value]\n return value"), 1
|
||||
)
|
||||
assert isinstance(output_result, MontyFailure), output_result
|
||||
assert output_result.kind == MontyErrorKind.OUTPUT_TOO_LARGE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_bound_input_is_rejected(runtime: MontyRuntime) -> None:
|
||||
result: Final = await _run(
|
||||
runtime, _compile("def transform(value): return value", argument={"type": "integer"}), "wrong"
|
||||
)
|
||||
assert isinstance(result, MontyFailure)
|
||||
assert result.kind == MontyErrorKind.INPUT_INVALID
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("name", ["default", "pattern", "enum", "$ref", "$ref-like"])
|
||||
async def test_keyword_looking_properties_validate_as_data(runtime: MontyRuntime, name: str) -> None:
|
||||
schema: Final[JsonSchema] = {
|
||||
"type": "object",
|
||||
"properties": {name: {"type": "string"}},
|
||||
"required": [name],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
compiled: Final = _compile("def transform(value): return value", schema, schema)
|
||||
result: Final = await _run(runtime, compiled, {name: "data"})
|
||||
assert isinstance(result, MontySuccess), result
|
||||
assert result.output == {name: "data"}
|
||||
invalid: Final = await _run(runtime, compiled, {name: 1})
|
||||
assert isinstance(invalid, MontyFailure), invalid
|
||||
assert invalid.kind == MontyErrorKind.INPUT_INVALID
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("output", [False, True])
|
||||
async def test_bypassed_remote_schema_never_retrieves_from_host(runtime: MontyRuntime, output: bool) -> None:
|
||||
requests: Final[asyncio.Queue[bytes]] = asyncio.Queue()
|
||||
|
||||
async def respond(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
requests.put_nowait(await reader.readuntil(b"\r\n\r\n"))
|
||||
writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
server: Final = await asyncio.start_server(respond, "127.0.0.1", 0)
|
||||
async with server:
|
||||
port: Final = server.sockets[0].getsockname()[1]
|
||||
schema: Final[JsonSchema] = {"properties": {"default": {"$ref": f"http://127.0.0.1:{port}/schema"}}}
|
||||
compiled: Final = _compile("def transform(value): return value")
|
||||
bypassed: Final = (
|
||||
replace(compiled, output_schema=schema)
|
||||
if output
|
||||
else replace(compiled, input_schema={"properties": {"value": schema}})
|
||||
)
|
||||
result: Final = await _run(runtime, bypassed, {"default": 1})
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == (MontyErrorKind.OUTPUT_INVALID if output else MontyErrorKind.INPUT_INVALID)
|
||||
assert requests.empty()
|
||||
|
||||
|
||||
def _slow_schema(depth: int) -> JsonSchema:
|
||||
definitions: Final[JsonSchema] = {
|
||||
f"N{index}": {"anyOf": [{"$ref": f"#/$defs/N{index + 1}"}, {"$ref": f"#/$defs/N{index + 1}"}]}
|
||||
for index in range(depth)
|
||||
}
|
||||
return {**definitions, f"N{depth}": {"type": "string"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("output", [False, True])
|
||||
async def test_expensive_allowed_validation_times_out_without_blocking_and_recovers(
|
||||
runtime: MontyRuntime, output: bool
|
||||
) -> None:
|
||||
ticks: Final[asyncio.Queue[float]] = asyncio.Queue()
|
||||
done: Final = asyncio.Event()
|
||||
|
||||
async def heartbeat() -> None:
|
||||
while not done.is_set():
|
||||
ticks.put_nowait(time.monotonic())
|
||||
await asyncio.sleep(0.005)
|
||||
|
||||
schema: Final = _slow_schema(22)
|
||||
ref: Final[JsonSchema] = {"$ref": "#/$defs/N0"}
|
||||
compiled: Final = _compile(
|
||||
"def transform(value): return value", ref if output else {}, {} if output else ref, schema
|
||||
)
|
||||
pulse: Final = asyncio.create_task(heartbeat())
|
||||
await asyncio.sleep(0.01)
|
||||
started: Final = time.monotonic()
|
||||
try:
|
||||
result: Final = await runtime.run_program(compiled, {"value": None}, deadline_monotonic=started + 0.15)
|
||||
elapsed: Final = time.monotonic() - started
|
||||
finally:
|
||||
done.set()
|
||||
await pulse
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.TIMEOUT
|
||||
assert 0.1 <= elapsed < 0.5 # test-quality-ok: validation enforces the node deadline
|
||||
observations: Final = tuple(ticks.get_nowait() for _ in range(ticks.qsize())) + (time.monotonic(),)
|
||||
assert len(observations) > 5
|
||||
assert max(later - earlier for earlier, later in zip(observations, observations[1:])) < 0.05
|
||||
recovered: Final = await _run(runtime, _compile("def transform(value): return value"), "recovered")
|
||||
assert isinstance(recovered, MontySuccess), recovered
|
||||
assert recovered.output == "recovered"
|
||||
|
||||
|
||||
def _validator_pids() -> frozenset[int]:
|
||||
def is_validator(child: psutil.Process) -> bool:
|
||||
try:
|
||||
return any(argument.endswith("_validator_worker.py") for argument in child.cmdline())
|
||||
except (psutil.NoSuchProcess, psutil.ZombieProcess):
|
||||
return False
|
||||
|
||||
return frozenset(child.pid for child in psutil.Process().children() if is_validator(child))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validator_environment_is_empty_and_close_reaps_processes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LITEFLOW_VALIDATOR_CANARY", "fake-value-no-credential")
|
||||
previous: Final = _validator_pids()
|
||||
instance: Final = MontyRuntime(MontyLimits(max_concurrent_workers=1))
|
||||
assert await instance.start() is None
|
||||
try:
|
||||
created: Final = _validator_pids() - previous
|
||||
assert len(created) == 1
|
||||
pid: Final = next(iter(created))
|
||||
assert b"LITEFLOW_VALIDATOR_CANARY" not in Path(f"/proc/{pid}/environ").read_bytes()
|
||||
assert Path(f"/proc/{pid}/environ").read_bytes() == b""
|
||||
result: Final = await _run(instance, _compile("def transform(value): return value"), 1)
|
||||
assert isinstance(result, MontySuccess), result
|
||||
assert _validator_pids() - previous == created
|
||||
finally:
|
||||
await instance.close()
|
||||
assert not (_validator_pids() - previous)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("cancel", [False, True])
|
||||
async def test_validator_crash_and_cancellation_release_slot_and_recover(runtime: MontyRuntime, cancel: bool) -> None:
|
||||
pid: Final = next(iter(_validator_pids()))
|
||||
compiled: Final = _compile(
|
||||
"def transform(value): return value", argument={"$ref": "#/$defs/N0"}, definitions=_slow_schema(22)
|
||||
)
|
||||
task: Final = asyncio.create_task(_run(runtime, compiled, None))
|
||||
await asyncio.sleep(0.05)
|
||||
if cancel:
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
else:
|
||||
os.kill(pid, 9) # test-quality-ok: deliberately crash the owned validator to prove slot and subprocess recovery
|
||||
result: Final = await task
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.WORKER_CRASHED
|
||||
assert pid not in _validator_pids()
|
||||
recovered: Final = await _run(runtime, _compile("def transform(value): return value"), "after failure")
|
||||
assert isinstance(recovered, MontySuccess), recovered
|
||||
assert recovered.output == "after failure"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_denies_file_access_even_if_compiler_is_bypassed(runtime: MontyRuntime) -> None:
|
||||
compiled: Final = _compile("def transform(value): return 42")
|
||||
unfiltered: Final = replace(compiled, sandbox_source="open('/etc/passwd').read()")
|
||||
result: Final = await _run(runtime, unfiltered, None)
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.RUNTIME_ERROR
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_deadline_cancels_running_worker_and_releases_slot(runtime: MontyRuntime) -> None:
|
||||
compiled: Final = _compile("def transform(value):\n while True: pass")
|
||||
result: Final = await runtime.run_program(compiled, {"value": None}, deadline_monotonic=time.monotonic() + 0.05)
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.TIMEOUT
|
||||
next_result: Final = await _run(runtime, _compile("def transform(value): return value"), 4)
|
||||
assert isinstance(next_result, MontySuccess), next_result
|
||||
assert next_result.output == 4
|
||||
|
||||
|
||||
def _worker_pids() -> frozenset[int]:
|
||||
children: Final = " ".join(
|
||||
path.read_text() for path in Path(f"/proc/{os.getpid()}/task").glob("*/children")
|
||||
).split()
|
||||
return frozenset(int(pid) for pid in children if Path(f"/proc/{pid}/comm").read_text().strip() == "monty")
|
||||
|
||||
|
||||
async def _active_worker(previous: frozenset[int]) -> int:
|
||||
async with asyncio.timeout(5):
|
||||
while True:
|
||||
current: Final = _worker_pids() - previous
|
||||
if current:
|
||||
return next(iter(current))
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_kills_worker_releases_slot_and_queue_is_bounded(runtime: MontyRuntime) -> None:
|
||||
previous: Final = _worker_pids()
|
||||
task: Final = asyncio.create_task(_run(runtime, _compile("def transform(value):\n while True: pass"), None))
|
||||
pid: Final = await _active_worker(previous)
|
||||
waiter: Final = asyncio.create_task(_run(runtime, _compile("def transform(value): return value"), 1))
|
||||
await asyncio.sleep(0)
|
||||
full: Final = await _run(runtime, _compile("def transform(value): return value"), 1)
|
||||
assert isinstance(full, MontyFailure)
|
||||
assert full.kind == MontyErrorKind.QUEUE_FULL
|
||||
waiter.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await waiter
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
result: Final = await _run(runtime, _compile("def transform(value): return value"), 2)
|
||||
assert isinstance(result, MontySuccess), result
|
||||
assert result.output == 2
|
||||
assert not Path(f"/proc/{pid}").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_killed_worker_fails_and_next_run_succeeds(runtime: MontyRuntime) -> None:
|
||||
previous: Final = _worker_pids()
|
||||
task: Final = asyncio.create_task(_run(runtime, _compile("def transform(value):\n while True: pass"), None))
|
||||
pid: Final = await _active_worker(previous)
|
||||
await asyncio.to_thread(stop_processes, (psutil.Process(pid),), 0.1)
|
||||
result: Final = await task
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.WORKER_CRASHED
|
||||
next_result: Final = await _run(runtime, _compile("def transform(value): return value"), 3)
|
||||
assert isinstance(next_result, MontySuccess), next_result
|
||||
assert next_result.output == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_worker_has_no_parent_canary_and_has_address_space_limit(
|
||||
runtime: MontyRuntime, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LITEFLOW_ENV_CANARY", "fake-value-no-credential")
|
||||
previous: Final = _worker_pids()
|
||||
task: Final = asyncio.create_task(_run(runtime, _compile("def transform(value):\n while True: pass"), None))
|
||||
pid: Final = await _active_worker(previous)
|
||||
assert b"LITEFLOW_ENV_CANARY" not in Path(f"/proc/{pid}/environ").read_bytes()
|
||||
async with asyncio.timeout(5):
|
||||
while str(runtime.limits.worker_address_space_bytes) not in Path(f"/proc/{pid}/limits").read_text():
|
||||
await asyncio.sleep(0.01)
|
||||
assert str(runtime.limits.worker_address_space_bytes) in Path(f"/proc/{pid}/limits").read_text()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
second: Final = asyncio.create_task(_run(runtime, _compile("def transform(value):\n while True: pass"), None))
|
||||
next_pid: Final = await _active_worker(previous | {pid})
|
||||
assert next_pid != pid
|
||||
second.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await second
|
||||
0
tests/unit/proxy/workflows/__init__.py
Normal file
0
tests/unit/proxy/workflows/__init__.py
Normal file
0
tests/unit/proxy/workflows/monty/__init__.py
Normal file
0
tests/unit/proxy/workflows/monty/__init__.py
Normal file
399
tests/unit/proxy/workflows/monty/test_compiler.py
Normal file
399
tests/unit/proxy/workflows/monty/test_compiler.py
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
import ast
|
||||
import hashlib
|
||||
from functools import reduce
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.workflows.monty.compiler import _SchemaWork, _schemas_error, compile_program, escape_diagnostics
|
||||
from litellm.proxy.workflows.monty.models import (
|
||||
CompiledProgram,
|
||||
JsonSchema,
|
||||
MontyErrorKind,
|
||||
MontyFailure,
|
||||
MontyLimits,
|
||||
PythonProgram,
|
||||
)
|
||||
|
||||
|
||||
def _program(source: str, *, output: JsonSchema | None = None) -> PythonProgram:
|
||||
return PythonProgram(
|
||||
contract_version="1",
|
||||
source=source,
|
||||
function_name="transform",
|
||||
parameter_names=("value",),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"value": {}},
|
||||
"required": ["value"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
output_schema=output if output is not None else {},
|
||||
source_sha256=hashlib.sha256(source.encode()).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[
|
||||
"import os\ndef transform(value): return value",
|
||||
"from typing import TypedDict\ndef transform(value): return value",
|
||||
"x=1\ndef transform(value): return value",
|
||||
"print('side effect')\ndef transform(value): return value",
|
||||
"async def transform(value): return value",
|
||||
"class X: pass\ndef transform(value): return value",
|
||||
"@print\ndef transform(value): return value",
|
||||
"def transform(value):\n def closure(): return value\n return closure()",
|
||||
"def transform(value): return (lambda: value)()",
|
||||
"def transform(value):\n global x\n return value",
|
||||
"def transform(value):\n nonlocal x\n return value",
|
||||
"def transform(value): return value.__class__",
|
||||
"def transform(value): return __name__",
|
||||
"# /// script\ndef transform(value): return value",
|
||||
"def transform(other): return other",
|
||||
"def transform(value=1): return value",
|
||||
"def transform(*value): return value",
|
||||
"def transform(value, **kwargs): return value",
|
||||
"def transform(*, value): return value",
|
||||
"def transform(value, /): return value",
|
||||
"def transform(value):\n import json\n return value",
|
||||
"def transform(value): return value\ndef isinstance(x,y): return True",
|
||||
],
|
||||
)
|
||||
def test_rejects_unsupported_source_without_running_it(source: str) -> None:
|
||||
program: Final = _program(source)
|
||||
result: Final = compile_program(program, MontyLimits())
|
||||
assert isinstance(result, MontyFailure)
|
||||
assert result.kind == MontyErrorKind.INVALID_PROGRAM
|
||||
assert "line" in result.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"builtin",
|
||||
[
|
||||
"eval",
|
||||
"exec",
|
||||
"compile",
|
||||
"open",
|
||||
"__import__",
|
||||
"globals",
|
||||
"locals",
|
||||
"vars",
|
||||
"getattr",
|
||||
"setattr",
|
||||
"delattr",
|
||||
"breakpoint",
|
||||
"input",
|
||||
"help",
|
||||
],
|
||||
)
|
||||
def test_rejects_dangerous_builtin_references(builtin: str) -> None:
|
||||
result: Final = compile_program(_program(f"def transform(value): return {builtin}"), MontyLimits())
|
||||
assert isinstance(result, MontyFailure)
|
||||
assert result.kind == MontyErrorKind.INVALID_PROGRAM
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema",
|
||||
[
|
||||
{"$ref": "https://example.invalid/schema"},
|
||||
{"$ref": "file:///etc/passwd"},
|
||||
{"$id": "https://example.invalid/schema"},
|
||||
{"$dynamicRef": "#/x"},
|
||||
{"$schema": "https://example.invalid/schema"},
|
||||
{"$ref": "#/$defs/Missing"},
|
||||
{"type": "not-a-json-type"},
|
||||
{"$defs": {"Bad": {"type": "object", "x-liteflow-record": True, "properties": {}}}},
|
||||
{
|
||||
"$defs": {
|
||||
"Bad-Name": {
|
||||
"type": "object",
|
||||
"x-liteflow-record": True,
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_rejects_unsupported_schema_without_remote_retrieval(schema: JsonSchema) -> None:
|
||||
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
|
||||
assert isinstance(result, MontyFailure)
|
||||
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
|
||||
|
||||
|
||||
def test_hash_covers_exact_source_bytes() -> None:
|
||||
program: Final = _program("def transform(value): return value")
|
||||
result: Final = compile_program(program.model_copy(update={"source": program.source + "\n"}), MontyLimits())
|
||||
assert isinstance(result, MontyFailure)
|
||||
assert result.kind == MontyErrorKind.INVALID_PROGRAM
|
||||
assert "source_sha256" in result.message
|
||||
|
||||
|
||||
def test_annotations_are_not_evaluated() -> None:
|
||||
result: Final = compile_program(
|
||||
_program("def transform(value: Unknown[Foo]) -> Missing: return value"), MontyLimits()
|
||||
)
|
||||
assert isinstance(result, CompiledProgram)
|
||||
|
||||
|
||||
def test_source_size_is_bounded() -> None:
|
||||
result: Final = compile_program(_program("def transform(value): return value"), MontyLimits(max_source_bytes=8))
|
||||
assert isinstance(result, MontyFailure)
|
||||
assert result.kind == MontyErrorKind.INVALID_PROGRAM
|
||||
|
||||
|
||||
def test_diagnostics_escape_terminal_controls_but_preserve_layout() -> None:
|
||||
assert escape_diagnostics("a\x1b[31m\x00\x7f\x9b\n\t") == "a\\x1b[31m\\x00\\x7f\\x9b\n\t"
|
||||
|
||||
|
||||
def _array_schema(child: JsonSchema, index: int) -> JsonSchema:
|
||||
return {"type": "array", "items": child}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("depth,accepted", [(4, True), (70, False)])
|
||||
def test_schema_nesting_is_bounded(depth: int, accepted: bool) -> None:
|
||||
schema: Final = reduce(_array_schema, range(depth), {})
|
||||
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
|
||||
if accepted:
|
||||
assert isinstance(result, CompiledProgram), result
|
||||
return
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
|
||||
assert "depth limit" in result.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("depth,accepted", [(4, True), (70, False)])
|
||||
def test_schema_reference_expansion_is_bounded(depth: int, accepted: bool) -> None:
|
||||
schema: Final[JsonSchema] = {
|
||||
"$defs": {
|
||||
f"N{index}": {"$ref": f"#/$defs/N{index + 1}"} if index < depth else {"type": "integer"}
|
||||
for index in range(depth + 1)
|
||||
},
|
||||
"$ref": "#/$defs/N0",
|
||||
}
|
||||
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
|
||||
if accepted:
|
||||
assert isinstance(result, CompiledProgram), result
|
||||
return
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
|
||||
assert "depth limit" in result.message
|
||||
|
||||
|
||||
def test_schema_reference_cycle_is_rejected() -> None:
|
||||
schema: Final[JsonSchema] = {"$defs": {"X": {"$ref": "#/$defs/X"}}, "$ref": "#/$defs/X"}
|
||||
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
|
||||
assert "recursive schema" in result.message
|
||||
|
||||
|
||||
_RECORD: Final[JsonSchema] = {
|
||||
"type": "object",
|
||||
"x-liteflow-record": True,
|
||||
"properties": {"amount": {"type": "integer"}, "label": {"type": ["string", "null"]}},
|
||||
"required": ["amount"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
_RECORD_REF: Final[JsonSchema] = {"$ref": "#/$defs/Record"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argument",
|
||||
[
|
||||
_RECORD_REF,
|
||||
{"type": "array", "items": _RECORD_REF},
|
||||
{"anyOf": [_RECORD_REF, {"type": "null"}]},
|
||||
{"type": "object", "properties": {"payload": _RECORD_REF}, "additionalProperties": True},
|
||||
{"type": "object", "properties": {}, "additionalProperties": _RECORD_REF},
|
||||
{"$ref": "#/$defs/Envelope"},
|
||||
],
|
||||
)
|
||||
def test_registration_accepts_records_in_supported_input_shapes(argument: JsonSchema) -> None:
|
||||
definitions: Final[JsonSchema] = {
|
||||
"Record": _RECORD,
|
||||
"Empty": {**_RECORD, "properties": {}, "required": []},
|
||||
"Envelope": {**_RECORD, "properties": {"child": _RECORD_REF}, "required": ["child"]},
|
||||
}
|
||||
program: Final = _program("def transform(value): return value", output={"$defs": definitions})
|
||||
typed: Final = program.model_copy(
|
||||
update={
|
||||
"input_schema": {
|
||||
**program.input_schema,
|
||||
"properties": {"value": argument},
|
||||
"$defs": definitions,
|
||||
}
|
||||
}
|
||||
)
|
||||
result: Final = compile_program(typed, MontyLimits())
|
||||
assert isinstance(result, CompiledProgram), result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema",
|
||||
[
|
||||
_RECORD,
|
||||
{"$defs": {"Record": {**_RECORD, "type": "string"}}},
|
||||
{"$defs": {"Record": {**_RECORD, "properties": {"bad-field": {}}}}},
|
||||
{"$defs": {"str": _RECORD}},
|
||||
{"$defs": {"transform": _RECORD}},
|
||||
{"$defs": {"Record": _RECORD}, "oneOf": [_RECORD_REF, {"type": "null"}]},
|
||||
{"$defs": {"Record": _RECORD}, "anyOf": [_RECORD_REF, {"type": "string"}]},
|
||||
{"$defs": {"Record": _RECORD}, "anyOf": [_RECORD_REF, {"type": "null"}, {"type": "string"}]},
|
||||
{"$defs": {"Record": _RECORD}, "allOf": [_RECORD_REF]},
|
||||
],
|
||||
)
|
||||
def test_registration_rejects_ambiguous_or_unconstructible_record_shapes(schema: JsonSchema) -> None:
|
||||
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changes",
|
||||
[
|
||||
{"type": "array"},
|
||||
{"properties": {"extra": {}}},
|
||||
{"required": []},
|
||||
{"required": ["value", "extra"]},
|
||||
{"additionalProperties": True},
|
||||
],
|
||||
)
|
||||
def test_input_schema_must_require_exactly_the_entry_parameters(changes: JsonSchema) -> None:
|
||||
program: Final = _program("def transform(value): return value")
|
||||
invalid: Final = program.model_copy(update={"input_schema": {**program.input_schema, **changes}})
|
||||
result: Final = compile_program(invalid, MontyLimits())
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
|
||||
|
||||
|
||||
def test_registration_rejects_conflicting_record_definitions() -> None:
|
||||
program: Final = _program("def transform(value): return value", output={"$defs": {"Record": _RECORD}})
|
||||
conflicting: Final = program.model_copy(
|
||||
update={"input_schema": {**program.input_schema, "$defs": {"Record": {**_RECORD, "required": []}}}}
|
||||
)
|
||||
result: Final = compile_program(conflicting, MontyLimits())
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
|
||||
assert "conflicting" in result.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
["def transform(value):\n", "def transform(value): return value\ndef transform(value): return 1"],
|
||||
)
|
||||
def test_registration_rejects_syntax_errors_and_duplicate_entry_names(source: str) -> None:
|
||||
result: Final = compile_program(_program(source), MontyLimits())
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.INVALID_PROGRAM
|
||||
|
||||
|
||||
def test_registration_accepts_local_json_pointer_escaping_and_annotations_import() -> None:
|
||||
schema: Final[JsonSchema] = {"$defs": {"a/b~c": {"type": "integer"}}, "$ref": "#/$defs/a~1b~0c"}
|
||||
source: Final = "from __future__ import annotations\ndef transform(value: Missing) -> Unknown: return value"
|
||||
result: Final = compile_program(_program(source, output=schema), MontyLimits())
|
||||
assert isinstance(result, CompiledProgram), result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["default", "pattern", "enum", "$ref", "$ref-like"])
|
||||
def test_keyword_looking_property_names_are_data(name: str) -> None:
|
||||
schema: Final[JsonSchema] = {"type": "object", "properties": {name: {"type": "string"}}}
|
||||
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
|
||||
assert isinstance(result, CompiledProgram), result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema",
|
||||
[
|
||||
{"properties": {"default": {"$ref": "https://example.invalid/schema"}}},
|
||||
{"$defs": {"default": {"items": {"$ref": "https://example.invalid/schema"}}}},
|
||||
{"anyOf": [{"properties": {"enum": {"$ref": "https://example.invalid/schema"}}}]},
|
||||
{"default": {"$ref": "https://example.invalid/schema"}, "$ref": "#/default"},
|
||||
],
|
||||
)
|
||||
def test_remote_references_under_keyword_looking_names_are_rejected(schema: JsonSchema) -> None:
|
||||
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
|
||||
assert "local" in result.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"keyword",
|
||||
[
|
||||
"pattern",
|
||||
"patternProperties",
|
||||
"uniqueItems",
|
||||
"$id",
|
||||
"$anchor",
|
||||
"$dynamicRef",
|
||||
"$dynamicAnchor",
|
||||
"$schema",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"dependentRequired",
|
||||
"dependentSchemas",
|
||||
"contains",
|
||||
"minContains",
|
||||
"maxContains",
|
||||
"propertyNames",
|
||||
"unevaluatedProperties",
|
||||
"unevaluatedItems",
|
||||
"discriminator",
|
||||
"unknown",
|
||||
],
|
||||
)
|
||||
def test_unsafe_keywords_are_explicitly_refused(keyword: str) -> None:
|
||||
result: Final = compile_program(_program("def transform(value): return value", output={keyword: {}}), MontyLimits())
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
|
||||
assert keyword in result.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("keyword", ["const", "enum", "default", "examples"])
|
||||
def test_annotation_and_data_values_are_not_interpreted_as_schemas(keyword: str) -> None:
|
||||
payload: Final[JsonSchema] = {"$ref": "https://example.invalid/data", "pattern": "inert"}
|
||||
schema: Final[JsonSchema] = {keyword: [payload] if keyword in {"enum", "examples"} else payload}
|
||||
result: Final = compile_program(_program("def transform(value): return value", output=schema), MontyLimits())
|
||||
assert isinstance(result, CompiledProgram), result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("depth", [8, 12, 14])
|
||||
def test_repeated_reference_analysis_has_linear_work(depth: int) -> None:
|
||||
definitions: Final[JsonSchema] = {
|
||||
f"N{index}": {"anyOf": [{"$ref": f"#/$defs/N{index + 1}"}, {"$ref": f"#/$defs/N{index + 1}"}]}
|
||||
for index in range(depth)
|
||||
}
|
||||
schema: Final[JsonSchema] = {"$defs": {**definitions, f"N{depth}": {}}, "$ref": "#/$defs/N0"}
|
||||
program: Final = _program("def transform(value): return value", output=schema)
|
||||
work: Final = _SchemaWork()
|
||||
assert _schemas_error(program, ast.parse(program.source), work) is None
|
||||
assert work.units < 100 * (depth + 1)
|
||||
result: Final = compile_program(program, MontyLimits())
|
||||
assert isinstance(result, CompiledProgram), result
|
||||
|
||||
|
||||
def test_expanded_input_builder_fails_closed_at_shared_work_budget() -> None:
|
||||
definitions: Final[JsonSchema] = {
|
||||
f"N{index}": {
|
||||
"type": "object",
|
||||
"properties": {"left": {"$ref": f"#/$defs/N{index + 1}"}, "right": {"$ref": f"#/$defs/N{index + 1}"}},
|
||||
}
|
||||
for index in range(14)
|
||||
}
|
||||
program: Final = _program("def transform(value): return value")
|
||||
expanded: Final = program.model_copy(
|
||||
update={
|
||||
"input_schema": {
|
||||
**program.input_schema,
|
||||
"$defs": {**definitions, "N14": {"type": "integer"}},
|
||||
"properties": {"value": {"$ref": "#/$defs/N0"}},
|
||||
}
|
||||
}
|
||||
)
|
||||
result: Final = compile_program(expanded, MontyLimits())
|
||||
assert isinstance(result, MontyFailure), result
|
||||
assert result.kind == MontyErrorKind.UNSUPPORTED_SCHEMA
|
||||
assert result.message == "schema expansion budget exceeded"
|
||||
55
tests/unit/proxy/workflows/monty/test_models.py
Normal file
55
tests/unit/proxy/workflows/monty/test_models.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.proxy.workflows.monty.models import MontyLimits, PythonProgram
|
||||
|
||||
|
||||
def _artifact() -> PythonProgram:
|
||||
return PythonProgram(
|
||||
contract_version="1",
|
||||
source="def transform(value): return value",
|
||||
function_name="transform",
|
||||
parameter_names=("value",),
|
||||
input_schema={},
|
||||
output_schema={},
|
||||
source_sha256="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def test_program_rejects_unknown_fields() -> None:
|
||||
with pytest.raises(ValidationError, match="extra_forbidden"):
|
||||
PythonProgram.model_validate({**_artifact().model_dump(), "credentials": "not-allowed"})
|
||||
|
||||
|
||||
def test_limits_reject_unknown_fields() -> None:
|
||||
with pytest.raises(ValidationError, match="extra_forbidden"):
|
||||
MontyLimits.model_validate({"mount": "/"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", tuple(MontyLimits.model_fields))
|
||||
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf"), -1, 0, 10**20])
|
||||
def test_every_limit_is_positive_finite_and_bounded(field: str, value: float | int) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
MontyLimits.model_validate({field: value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("function_name", "not valid"),
|
||||
("function_name", "class"),
|
||||
("function_name", "x" * 129),
|
||||
("parameter_names", ("bad-name",)),
|
||||
("parameter_names", ("x" * 129,)),
|
||||
("parameter_names", tuple(f"p{i}" for i in range(33))),
|
||||
("source_sha256", "A" * 64),
|
||||
("source_sha256", "a" * 63),
|
||||
("source_sha256", "g" * 64),
|
||||
],
|
||||
)
|
||||
def test_artifact_names_count_and_digest_are_bounded(field: str, value: object) -> None:
|
||||
payload: Final = {**_artifact().model_dump(), field: value}
|
||||
with pytest.raises(ValidationError):
|
||||
PythonProgram.model_validate(payload)
|
||||
145
uv.lock
generated
145
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue