wassitai 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. llm_parser/__init__.py +116 -0
  2. wassitai/__init__.py +50 -0
  3. wassitai/_version.py +1 -0
  4. wassitai/api/__init__.py +3 -0
  5. wassitai/api/app.py +61 -0
  6. wassitai/api/deps.py +49 -0
  7. wassitai/api/errors.py +53 -0
  8. wassitai/api/models.py +42 -0
  9. wassitai/api/routes.py +93 -0
  10. wassitai/api/static/app.js +405 -0
  11. wassitai/api/static/favicon.svg +9 -0
  12. wassitai/api/static/guide.html +357 -0
  13. wassitai/api/static/help.js +88 -0
  14. wassitai/api/static/index.html +379 -0
  15. wassitai/api/static/styles.css +874 -0
  16. wassitai/api/static/theme.js +30 -0
  17. wassitai/api/static/vendor/alpine.min.js +5 -0
  18. wassitai/api/static/vendor/fonts/instrument-serif-italic-400.woff2 +0 -0
  19. wassitai/api/static/vendor/fonts/instrument-serif-normal-400.woff2 +0 -0
  20. wassitai/api/static/vendor/fonts/inter-normal-400.woff2 +0 -0
  21. wassitai/api/static/vendor/fonts/jetbrains-mono-normal-400.woff2 +0 -0
  22. wassitai/api/static/vendor/fonts.css +60 -0
  23. wassitai/client.py +151 -0
  24. wassitai/config.py +16 -0
  25. wassitai/core/formatter.py +76 -0
  26. wassitai/core/formatter_utils.py +78 -0
  27. wassitai/core/model_parser.py +139 -0
  28. wassitai/core/model_validator.py +46 -0
  29. wassitai/core/payload_validator.py +117 -0
  30. wassitai/core/prompt_builder.py +95 -0
  31. wassitai/errors.py +90 -0
  32. wassitai/llm_parser.py +63 -0
  33. wassitai/llms/__init__.py +22 -0
  34. wassitai/llms/base.py +12 -0
  35. wassitai/llms/const.py +8 -0
  36. wassitai/llms/providers/deepseek_provider.py +21 -0
  37. wassitai/llms/providers/gemini_provider.py +24 -0
  38. wassitai/llms/providers/groq_provider.py +28 -0
  39. wassitai/llms/providers/huggingface_provider.py +56 -0
  40. wassitai/llms/providers/openai_provider.py +21 -0
  41. wassitai/llms/providers/openrouter_provider.py +39 -0
  42. wassitai/pipeline/__init__.py +37 -0
  43. wassitai/pipeline/base.py +18 -0
  44. wassitai/pipeline/context.py +25 -0
  45. wassitai/pipeline/stages/__init__.py +0 -0
  46. wassitai/pipeline/stages/coerce.py +11 -0
  47. wassitai/pipeline/stages/deserialize.py +19 -0
  48. wassitai/pipeline/stages/extract.py +33 -0
  49. wassitai/pipeline/stages/normalize.py +12 -0
  50. wassitai/pipeline/stages/syntax_repair.py +17 -0
  51. wassitai/pipeline/stages/validate.py +23 -0
  52. wassitai/providers/__init__.py +68 -0
  53. wassitai/providers/base.py +73 -0
  54. wassitai/providers/openai_compatible.py +107 -0
  55. wassitai/providers/transport.py +60 -0
  56. wassitai/render.py +28 -0
  57. wassitai/result.py +40 -0
  58. wassitai/schema/__init__.py +43 -0
  59. wassitai/schema/base.py +12 -0
  60. wassitai/schema/dict_adapter.py +80 -0
  61. wassitai/schema/json_string_adapter.py +37 -0
  62. wassitai/schema/pydantic_adapter.py +51 -0
  63. wassitai/schema/spec.py +12 -0
  64. wassitai/strategies/__init__.py +34 -0
  65. wassitai/strategies/base.py +15 -0
  66. wassitai/strategies/native.py +23 -0
  67. wassitai/strategies/prompt_repair.py +26 -0
  68. wassitai/strategies/tool_calling.py +27 -0
  69. wassitai-0.2.0.dist-info/METADATA +224 -0
  70. wassitai-0.2.0.dist-info/RECORD +73 -0
  71. wassitai-0.2.0.dist-info/WHEEL +5 -0
  72. wassitai-0.2.0.dist-info/licenses/LICENSE +21 -0
  73. wassitai-0.2.0.dist-info/top_level.txt +2 -0
llm_parser/__init__.py ADDED
@@ -0,0 +1,116 @@
1
+ """Deprecated import alias for :mod:`wassitai`.
2
+
3
+ The distribution was renamed ``llm_parser`` -> ``wassitai`` in 0.2. This package
4
+ exists only so code written against the old name keeps working: it forwards
5
+ every attribute *and* every submodule to :mod:`wassitai`, so both
6
+
7
+ from llm_parser import Client, parse
8
+ from llm_parser.llm_parser import LLMParser
9
+ from llm_parser.core import formatter
10
+
11
+ resolve to the very same objects the ``wassitai.*`` paths give you -- not copies.
12
+ It will be removed in 2.0; import from ``wassitai`` instead.
13
+
14
+ Two deliberate limits, neither of which any caller in this repo relies on:
15
+ ``pkgutil.iter_modules(llm_parser.__path__)`` enumerates nothing (this directory
16
+ really is empty), and ``python -m llm_parser.<mod>`` does not work. Use the
17
+ ``wassitai`` names for either.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib
23
+ import importlib.abc
24
+ import importlib.util
25
+ import sys
26
+ import warnings
27
+
28
+ import wassitai as _wassitai
29
+
30
+ _OLD = "llm_parser"
31
+ _NEW = "wassitai"
32
+
33
+
34
+ class _AliasLoader(importlib.abc.Loader):
35
+ """Loader that hands back an already-imported ``wassitai`` submodule."""
36
+
37
+ def __init__(self, real_name: str) -> None:
38
+ self._real_name = real_name
39
+ self._real_spec = None
40
+
41
+ def create_module(self, spec):
42
+ real = importlib.import_module(self._real_name)
43
+ self._real_spec = real.__spec__
44
+ return real
45
+
46
+ def exec_module(self, module) -> None:
47
+ # The real module was executed under its own name; re-running it would
48
+ # duplicate its state (registries, module-level singletons) under a
49
+ # second identity, which is exactly what this alias exists to prevent.
50
+ #
51
+ # Undo the one thing the import machinery did to it on the way here:
52
+ # `_init_module_attrs` overwrites `__spec__` unconditionally, so
53
+ # importing `llm_parser.core.formatter` would otherwise rename the live
54
+ # `wassitai.core.formatter` and blank its `submodule_search_locations` --
55
+ # breaking `reload()` and `importlib.resources` for callers that never
56
+ # touched the old name. The alias forwards one way only.
57
+ if self._real_spec is not None:
58
+ module.__spec__ = self._real_spec
59
+
60
+
61
+ class _AliasFinder(importlib.abc.MetaPathFinder):
62
+ """Maps ``llm_parser.<sub>`` onto ``wassitai.<sub>``."""
63
+
64
+ # Duck-typed marker rather than isinstance: re-importing this module (tests
65
+ # do, to observe the warning) rebinds the class, so an isinstance check
66
+ # against the fresh class would miss the finder already on sys.meta_path and
67
+ # stack up a duplicate on every reload.
68
+ _wassitai_alias_finder = True
69
+
70
+ def find_spec(self, fullname, path=None, target=None):
71
+ if not fullname.startswith(_OLD + "."):
72
+ return None
73
+ real_name = _NEW + fullname[len(_OLD) :]
74
+ try:
75
+ real = importlib.import_module(real_name)
76
+ except ModuleNotFoundError as exc:
77
+ if exc.name != real_name:
78
+ # The module exists but its *own* imports failed -- a missing
79
+ # optional dependency, say. Swallowing that would report the
80
+ # alias as absent and destroy the real cause.
81
+ raise
82
+ return None # genuinely absent: let the machinery raise as usual
83
+ return importlib.util.spec_from_loader(
84
+ fullname,
85
+ _AliasLoader(real_name),
86
+ is_package=hasattr(real, "__path__"),
87
+ )
88
+
89
+
90
+ # Ahead of the path-based finder, which would otherwise fail to find submodules
91
+ # that no longer live in this (now almost empty) directory.
92
+ if not any(getattr(f, "_wassitai_alias_finder", False) for f in sys.meta_path):
93
+ sys.meta_path.insert(0, _AliasFinder())
94
+
95
+ warnings.warn(
96
+ "The 'llm_parser' package was renamed to 'wassitai'; install it with "
97
+ "`pip install wassitai` and import `wassitai`. The 'llm_parser' alias will "
98
+ "be removed in 2.0.",
99
+ DeprecationWarning,
100
+ stacklevel=2,
101
+ )
102
+
103
+ __all__ = list(_wassitai.__all__)
104
+
105
+
106
+ def __getattr__(name: str):
107
+ # Delegated rather than star-imported so the alias never drifts from the
108
+ # real package's surface as that surface grows.
109
+ try:
110
+ return getattr(_wassitai, name)
111
+ except AttributeError:
112
+ raise AttributeError(f"module {_OLD!r} has no attribute {name!r}") from None
113
+
114
+
115
+ def __dir__():
116
+ return sorted(set(__all__) | set(globals()))
wassitai/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from ._version import __version__
4
+ from .client import Client, parse, try_parse
5
+ from .config import Config
6
+ from .errors import (
7
+ AuthenticationError,
8
+ ConfigurationError,
9
+ Diagnostic,
10
+ ExtractionError,
11
+ LLMParserError,
12
+ ParseError,
13
+ ProviderError,
14
+ ProviderHTTPError,
15
+ ProviderResponseError,
16
+ ProviderTimeoutError,
17
+ RateLimitError,
18
+ RepairError,
19
+ SchemaError,
20
+ SchemaValidationError,
21
+ UnknownProviderError,
22
+ )
23
+ from .render import render
24
+ from .result import Result, Usage
25
+
26
+ __all__ = [
27
+ "__version__",
28
+ "Client",
29
+ "parse",
30
+ "try_parse",
31
+ "Config",
32
+ "Result",
33
+ "Usage",
34
+ "render",
35
+ "Diagnostic",
36
+ "LLMParserError",
37
+ "ConfigurationError",
38
+ "UnknownProviderError",
39
+ "SchemaError",
40
+ "ProviderError",
41
+ "AuthenticationError",
42
+ "RateLimitError",
43
+ "ProviderTimeoutError",
44
+ "ProviderHTTPError",
45
+ "ProviderResponseError",
46
+ "ParseError",
47
+ "ExtractionError",
48
+ "RepairError",
49
+ "SchemaValidationError",
50
+ ]
wassitai/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .app import app, create_app
2
+
3
+ __all__ = ["app", "create_app"]
wassitai/api/app.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import FileResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+
10
+ from .._version import __version__
11
+ from .errors import register_exception_handlers
12
+ from .routes import router
13
+
14
+ _STATIC = Path(__file__).parent / "static"
15
+
16
+
17
+ class _RevalidatingStaticFiles(StaticFiles):
18
+ """Serve static assets with ``Cache-Control: no-cache``.
19
+
20
+ Plain ``StaticFiles`` sends no ``Cache-Control``, so browsers apply heuristic
21
+ freshness and can keep running a stale ``app.js``/``styles.css`` after an edit
22
+ without ever re-requesting it. ``no-cache`` means "cache, but always
23
+ revalidate": the browser issues a conditional request and gets a cheap 304
24
+ when nothing changed, or the fresh file the moment it does — so the playground
25
+ never boots old JS against a new page.
26
+ """
27
+
28
+ async def get_response(self, path: str, scope): # type: ignore[override]
29
+ response = await super().get_response(path, scope)
30
+ response.headers.setdefault("Cache-Control", "no-cache")
31
+ return response
32
+
33
+
34
+ def create_app() -> FastAPI:
35
+ app = FastAPI(
36
+ title="Wassitai API",
37
+ # One version for the whole distribution: a hardcoded literal here drifts
38
+ # from the package the moment either is bumped, and it is what `/docs` and
39
+ # the OpenAPI schema advertise.
40
+ version=__version__,
41
+ description="Structured generation & LLM-output parsing over HTTP.",
42
+ )
43
+ app.add_middleware(
44
+ CORSMiddleware,
45
+ allow_origins=["*"],
46
+ allow_methods=["*"],
47
+ allow_headers=["*"],
48
+ )
49
+ app.include_router(router)
50
+ register_exception_handlers(app)
51
+
52
+ app.mount("/static", _RevalidatingStaticFiles(directory=str(_STATIC)), name="static")
53
+
54
+ @app.get("/", include_in_schema=False)
55
+ def index() -> FileResponse:
56
+ return FileResponse(str(_STATIC / "index.html"), headers={"Cache-Control": "no-cache"})
57
+
58
+ return app
59
+
60
+
61
+ app = create_app()
wassitai/api/deps.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Optional
5
+
6
+ from wassitai import Client
7
+ from wassitai.config import Config
8
+ from wassitai.errors import AuthenticationError
9
+ from wassitai.strategies.native import NativeStructuredOutputStrategy
10
+ from wassitai.strategies.prompt_repair import PromptAndRepairStrategy
11
+ from wassitai.strategies.tool_calling import ToolCallingStrategy
12
+
13
+ _STRATEGIES = {
14
+ "native": NativeStructuredOutputStrategy,
15
+ "tool_calling": ToolCallingStrategy,
16
+ "prompt_repair": PromptAndRepairStrategy,
17
+ }
18
+
19
+
20
+ def resolve_key(provider: str, header_key: Optional[str]) -> str:
21
+ if header_key:
22
+ return header_key
23
+ env = os.getenv(f"{provider.upper()}_API_KEY")
24
+ if env:
25
+ return env
26
+ raise AuthenticationError(
27
+ f"No API key for '{provider}'. Supply the X-Provider-Key header "
28
+ f"or set {provider.upper()}_API_KEY on the server."
29
+ )
30
+
31
+
32
+ def build_client(
33
+ provider: str,
34
+ key: str,
35
+ model: Optional[str] = None,
36
+ max_retries: Optional[int] = None,
37
+ strict: bool = False,
38
+ strategy: Optional[str] = None,
39
+ ) -> Client:
40
+ strat = _STRATEGIES[strategy]() if strategy in _STRATEGIES else None
41
+ cfg = Config(
42
+ strict=strict,
43
+ max_retries=1 if max_retries is None else max_retries,
44
+ strategy=strat,
45
+ )
46
+ kw = {"api_key": key, "config": cfg}
47
+ if model:
48
+ kw["model"] = model
49
+ return Client.from_provider(provider, **kw)
wassitai/api/errors.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import Request
4
+ from fastapi.responses import JSONResponse
5
+
6
+ from wassitai.errors import (
7
+ AuthenticationError,
8
+ LLMParserError,
9
+ ParseError,
10
+ ProviderHTTPError,
11
+ ProviderResponseError,
12
+ ProviderTimeoutError,
13
+ RateLimitError,
14
+ SchemaError,
15
+ SchemaValidationError,
16
+ UnknownProviderError,
17
+ )
18
+
19
+ # Order matters: most specific first.
20
+ _STATUS_TABLE = [
21
+ (AuthenticationError, 401),
22
+ (RateLimitError, 429),
23
+ (ProviderTimeoutError, 504),
24
+ (ProviderHTTPError, 502),
25
+ (ProviderResponseError, 502),
26
+ (UnknownProviderError, 400),
27
+ (SchemaError, 422),
28
+ (SchemaValidationError, 422),
29
+ (ParseError, 422),
30
+ ]
31
+
32
+
33
+ def status_for(exc: LLMParserError) -> int:
34
+ for cls, code in _STATUS_TABLE:
35
+ if isinstance(exc, cls):
36
+ return code
37
+ return 500
38
+
39
+
40
+ def error_body(exc: LLMParserError) -> dict:
41
+ body: dict = {"error": str(exc), "error_type": type(exc).__name__}
42
+ if isinstance(exc, ParseError):
43
+ body["diagnostics"] = [
44
+ {"stage": d.stage, "level": d.level, "message": d.message} for d in exc.diagnostics
45
+ ]
46
+ body["confidence"] = exc.confidence
47
+ return body
48
+
49
+
50
+ def register_exception_handlers(app) -> None:
51
+ @app.exception_handler(LLMParserError)
52
+ async def _handle(request: Request, exc: LLMParserError):
53
+ return JSONResponse(status_code=status_for(exc), content=error_body(exc))
wassitai/api/models.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional, Union
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class GenerateRequest(BaseModel):
9
+ provider: str
10
+ prompt: str
11
+ schema_: Union[dict, str] = Field(alias="schema")
12
+ model: Optional[str] = None
13
+ strict: bool = False
14
+ max_retries: Optional[int] = None
15
+ strategy: Optional[str] = None
16
+ render: Optional[str] = None # "json" | "xml" | None (return the object)
17
+
18
+ model_config = {"populate_by_name": True}
19
+
20
+
21
+ class ParseRequest(BaseModel):
22
+ text: str
23
+ schema_: Union[dict, str] = Field(alias="schema")
24
+ strict: bool = False
25
+ render: Optional[str] = None
26
+
27
+ model_config = {"populate_by_name": True}
28
+
29
+
30
+ class GenerateResponse(BaseModel):
31
+ ok: bool
32
+ value: Any = None
33
+ rendered: Optional[str] = None
34
+ confidence: float = 1.0
35
+ diagnostics: list[dict[str, Any]] = []
36
+ usage: Optional[dict[str, int]] = None
37
+ error: Optional[str] = None
38
+
39
+
40
+ class ProviderInfo(BaseModel):
41
+ name: str
42
+ capabilities: dict[str, Any]
wassitai/api/routes.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from fastapi import APIRouter, Header
6
+ from pydantic import BaseModel
7
+
8
+ from wassitai import __version__, try_parse
9
+ from wassitai import render as render_obj
10
+ from wassitai.providers import _PRESETS
11
+
12
+ from .deps import build_client, resolve_key
13
+ from .models import GenerateRequest, GenerateResponse, ParseRequest
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ def _jsonable(value: object) -> object:
19
+ if isinstance(value, BaseModel):
20
+ return value.model_dump()
21
+ if isinstance(value, list):
22
+ return [_jsonable(v) for v in value]
23
+ return value
24
+
25
+
26
+ def _diagnostics(result) -> list:
27
+ return [
28
+ {"stage": d.stage, "level": d.level, "message": d.message} for d in result.diagnostics
29
+ ]
30
+
31
+
32
+ @router.get("/health")
33
+ def health() -> dict:
34
+ return {"status": "ok", "version": __version__}
35
+
36
+
37
+ @router.post("/v1/parse", response_model=GenerateResponse)
38
+ def parse_route(req: ParseRequest) -> GenerateResponse:
39
+ result = try_parse(req.text, req.schema_, strict=req.strict)
40
+ value = result.unwrap() # raises the typed error; mapped to a status by the handler
41
+ rendered = render_obj(value, fmt=req.render) if req.render else None
42
+ return GenerateResponse(
43
+ ok=True,
44
+ value=_jsonable(value),
45
+ rendered=rendered,
46
+ confidence=result.confidence,
47
+ diagnostics=_diagnostics(result),
48
+ )
49
+
50
+
51
+ @router.post("/v1/generate", response_model=GenerateResponse)
52
+ def generate_route(
53
+ req: GenerateRequest,
54
+ x_provider_key: Optional[str] = Header(default=None),
55
+ ) -> GenerateResponse:
56
+ key = resolve_key(req.provider, x_provider_key) # -> 401 if missing
57
+ client = build_client(
58
+ req.provider,
59
+ key,
60
+ model=req.model,
61
+ max_retries=req.max_retries,
62
+ strict=req.strict,
63
+ strategy=req.strategy,
64
+ )
65
+ result = client.try_generate(req.prompt, schema=req.schema_)
66
+ value = result.unwrap() # raises the typed error; mapped to a status by the handler
67
+ rendered = render_obj(value, fmt=req.render) if req.render else None
68
+ return GenerateResponse(
69
+ ok=True,
70
+ value=_jsonable(value),
71
+ rendered=rendered,
72
+ confidence=result.confidence,
73
+ diagnostics=_diagnostics(result),
74
+ usage=(result.usage.__dict__ if result.usage else None),
75
+ )
76
+
77
+
78
+ @router.get("/v1/providers")
79
+ def list_providers() -> dict:
80
+ return {
81
+ "providers": [
82
+ {
83
+ "name": name,
84
+ "capabilities": {
85
+ "openai_compatible": True,
86
+ "tool_calling": True,
87
+ "json_mode": True,
88
+ },
89
+ "default_model": preset[1],
90
+ }
91
+ for name, preset in _PRESETS.items()
92
+ ]
93
+ }