pyyapi 0.1.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.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyyapi
3
+ Version: 0.1.0
4
+ Summary: Prompt-first declarative HTTP framework on top of FastAPI and PydanticAI
5
+ Author-email: DJJ <shuaiqijianhao@qq.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/TokenRollAI/yapi
8
+ Project-URL: Repository, https://github.com/TokenRollAI/yapi
9
+ Project-URL: Issues, https://github.com/TokenRollAI/yapi/issues
10
+ Keywords: fastapi,pydantic,pydantic-ai,llm,prompt,http,framework,declarative
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Framework :: FastAPI
19
+ Classifier: Framework :: Pydantic
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: fastapi<1,>=0.115
27
+ Requires-Dist: pydantic<3,>=2.7
28
+ Requires-Dist: pydantic-ai<1,>=0.0.18
29
+ Requires-Dist: uvicorn<1,>=0.30
30
+ Provides-Extra: dev
31
+ Requires-Dist: httpx<1,>=0.27; extra == "dev"
32
+ Requires-Dist: pytest<9,>=8.2; extra == "dev"
33
+ Requires-Dist: pytest-asyncio<1,>=0.23; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # yapi
37
+
38
+ [![PyPI](https://img.shields.io/pypi/v/pyyapi.svg)](https://pypi.org/project/pyyapi/)
39
+ [![Python](https://img.shields.io/pypi/pyversions/pyyapi.svg)](https://pypi.org/project/pyyapi/)
40
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
41
+
42
+ **Prompt-first declarative HTTP framework** — write a normal Python function with a docstring, get an LLM-powered HTTP endpoint with structured JSON responses.
43
+
44
+ `yapi` is a thin layer on top of [FastAPI](https://fastapi.tiangolo.com/) and [PydanticAI](https://ai.pydantic.dev/). You declare an HTTP route by decorating a regular function; the framework takes the function signature, the response model's docstring, the function's docstring and any dynamic prompt it returns, composes them into a system prompt, and hands the result to a PydanticAI `Agent` to produce a validated `BaseModel` response.
45
+
46
+ > Package name on PyPI is `pyyapi` (the unhyphenated `yapi` was taken by a 2018 project). Import path is still `yapi`.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install pyyapi
52
+ ```
53
+
54
+ Python 3.12+ required.
55
+
56
+ ## Quick start
57
+
58
+ ```python
59
+ from fastapi import FastAPI
60
+ from pydantic import BaseModel
61
+
62
+ from yapi import PromptRouter
63
+
64
+
65
+ class WishIn(BaseModel):
66
+ user_id: str
67
+ wish: str
68
+
69
+
70
+ class WishOut(BaseModel):
71
+ """You are a wish-granting entity. Decide whether to grant the wish."""
72
+
73
+ granted: bool
74
+ message: str
75
+
76
+
77
+ app = FastAPI(title="yapi showcase")
78
+ router = PromptRouter()
79
+
80
+
81
+ @router.post("/wish")
82
+ def make_a_wish(req: WishIn) -> WishOut:
83
+ """Decide whether to grant the user's wish."""
84
+
85
+
86
+ app.include_router(router)
87
+ ```
88
+
89
+ Run it:
90
+
91
+ ```bash
92
+ YAPI_MODEL=test uvicorn examples.wish_api:app --reload
93
+ ```
94
+
95
+ `YAPI_MODEL=test` activates PydanticAI's built-in `TestModel` — no API key, no network, perfect for offline smoke tests. For real models, set e.g. `YAPI_MODEL=openai:gpt-4o` or `YAPI_MODEL=anthropic:claude-3-5-sonnet`.
96
+
97
+ Open `http://localhost:8000/docs` for the auto-generated OpenAPI UI.
98
+
99
+ ## How it works
100
+
101
+ For each request, `yapi`:
102
+
103
+ 1. parses the request body into the `BaseModel` you declared as a parameter,
104
+ 2. calls your function synchronously to optionally produce a **dynamic prompt** (the function's `return` value, must be `None` or `str`),
105
+ 3. composes the final system prompt from: response-model docstring + function docstring + dynamic prompt,
106
+ 4. invokes the configured `agent_runner` (defaulting to a PydanticAI `Agent`) with the prompt + request payload,
107
+ 5. validates the agent's output against your return annotation and serializes via FastAPI.
108
+
109
+ ## Contract (hard rules)
110
+
111
+ - `PromptRouter` subclasses `fastapi.APIRouter` and overrides `.get/.post/.put/.patch/.delete`. All routes on a `PromptRouter` are prompt routes — don't mix plain FastAPI routes onto it.
112
+ - The decorator only accepts the path argument. FastAPI kwargs like `tags=`, `summary=`, `status_code=`, `response_class=`, `response_model=` are silently ignored.
113
+ - Return annotation **must** be a `BaseModel` subclass.
114
+ - At most one parameter may be a `BaseModel` (the request body). Other parameters must have `default=fastapi.Depends(...)`.
115
+ - Function body must `return` either `None` or a `str` (the dynamic prompt). Anything else raises at request time. `async def` is not supported.
116
+
117
+ Violations are raised as `YapiDeclarationError` at decoration time — broken routes fail at import, not at request time.
118
+
119
+ ## Dependency injection
120
+
121
+ ```python
122
+ from fastapi import Depends
123
+
124
+ def get_db():
125
+ ...
126
+
127
+ @router.post("/wish")
128
+ def make_a_wish(req: WishIn, db = Depends(get_db)) -> WishOut:
129
+ """..."""
130
+ return f"user has {db.balance(req.user_id)} wishes left"
131
+ ```
132
+
133
+ ## Custom agent runner
134
+
135
+ `PromptRouter(agent_runner=...)` accepts any callable with the signature `(*, prompt, request, injected, response_model) -> dict`. Useful for tests:
136
+
137
+ ```python
138
+ router = PromptRouter(
139
+ agent_runner=lambda **_: {"granted": True, "message": "ok"},
140
+ )
141
+ ```
142
+
143
+ ## Development
144
+
145
+ ```bash
146
+ uv sync --extra dev
147
+ uv run pytest
148
+ uv run uvicorn examples.wish_api:app --reload
149
+ ```
150
+
151
+ ## License
152
+
153
+ MIT
@@ -0,0 +1,12 @@
1
+ pyyapi-0.1.0.dist-info/licenses/LICENSE,sha256=do6SIX12qLn8oPKQradS4jQ1Et8t0dIMrOgzzIW-raU,1060
2
+ yapi/__init__.py,sha256=ej4HIY4qTJaPPg4cuMdQDJAMWGJ-wmc1racmFqlHjgY,65
3
+ yapi/agent.py,sha256=WHe2YMqF1kVZMttn8QY7frFyDH_tV1Jh0gXCtQ1NO8U,1151
4
+ yapi/endpoint.py,sha256=xsQHbMhQUFQOhlUbLymVVGPb-RO8dW3jZx2pnrYTMq8,394
5
+ yapi/errors.py,sha256=rcmfyO6Qhh8G0FfUBAtV5unBiTAsf7ZloIMY9QIiNfE,183
6
+ yapi/models.py,sha256=PXutepPXwoKlZFg6u1Ka4yVF5C0rR3nt1lmw36kJySM,142
7
+ yapi/router.py,sha256=MVrZU66Qt3HViwBap6LyS82ahJFPWtk581KgB3v8Ijs,5368
8
+ yapi/runtime.py,sha256=8LEqP1VsXaAPVmlvExHsruXWQ-3WUzr0oOYQBnOmd6c,1949
9
+ pyyapi-0.1.0.dist-info/METADATA,sha256=FM9lt8l5j5I2GZssOm_3z40xNokQDpQ6XRbHh8j_vNM,5455
10
+ pyyapi-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ pyyapi-0.1.0.dist-info/top_level.txt,sha256=9CRrEew74JkNhm0PcYmHznjQ2F8xI4_Rkys48bjxl6U,5
12
+ pyyapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DJJ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ yapi
yapi/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from yapi.router import PromptRouter
2
+
3
+ __all__ = ["PromptRouter"]
yapi/agent.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Callable
5
+
6
+ from pydantic import BaseModel
7
+ from pydantic_ai import Agent
8
+
9
+
10
+ DEFAULT_SYSTEM_PREFIX = (
11
+ "You are the execution engine behind a declarative HTTP endpoint. "
12
+ "Return data that matches the required response model exactly."
13
+ )
14
+
15
+
16
+ def build_agent_runner(model: str | None = None) -> Callable[..., dict]:
17
+ configured_model = model or os.getenv("YAPI_MODEL")
18
+
19
+ def runner(
20
+ *,
21
+ prompt: str,
22
+ request: dict,
23
+ injected: dict,
24
+ response_model: type[BaseModel],
25
+ ) -> dict:
26
+ if configured_model is None:
27
+ raise NotImplementedError("Connect pydantic_ai.Agent by setting YAPI_MODEL")
28
+
29
+ agent = Agent(
30
+ configured_model,
31
+ output_type=response_model,
32
+ system_prompt=prompt,
33
+ )
34
+
35
+ user_prompt = f"request={request}\ninjected={injected}"
36
+ result = agent.run_sync(user_prompt)
37
+ output = getattr(result, "output", result)
38
+ if hasattr(output, "model_dump"):
39
+ return output.model_dump()
40
+ return dict(output)
41
+
42
+ return runner
yapi/endpoint.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class PromptEndpoint:
10
+ path: str
11
+ method: str
12
+ request_model: type[BaseModel] | None
13
+ response_model: type[BaseModel]
14
+ function_doc: str = ""
15
+
16
+ @property
17
+ def response_doc(self) -> str:
18
+ return (self.response_model.__doc__ or "").strip()
yapi/errors.py ADDED
@@ -0,0 +1,14 @@
1
+ class YapiError(Exception):
2
+ pass
3
+
4
+
5
+ class YapiDeclarationError(YapiError):
6
+ pass
7
+
8
+
9
+ class StateStoreError(YapiError):
10
+ pass
11
+
12
+
13
+ class RuntimeExecutionError(YapiError):
14
+ pass
yapi/models.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class RuntimeContext:
8
+ request: dict
9
+ injected: dict
yapi/router.py ADDED
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Callable
5
+
6
+ from fastapi import APIRouter, params
7
+ from fastapi.concurrency import run_in_threadpool
8
+ from pydantic import BaseModel
9
+
10
+ from yapi.agent import build_agent_runner
11
+ from yapi.endpoint import PromptEndpoint
12
+ from yapi.errors import RuntimeExecutionError, YapiDeclarationError
13
+ from yapi.runtime import Runtime
14
+
15
+ _HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
16
+
17
+
18
+ def _introspect(func: Callable) -> tuple[type[BaseModel] | None, type[BaseModel], list[tuple[str, inspect.Parameter]]]:
19
+ signature = inspect.signature(func)
20
+
21
+ return_annotation = signature.return_annotation
22
+ if return_annotation is inspect.Signature.empty:
23
+ raise YapiDeclarationError(
24
+ f"yapi route handler '{func.__name__}' must declare a return type annotation"
25
+ )
26
+ if not (isinstance(return_annotation, type) and issubclass(return_annotation, BaseModel)):
27
+ raise YapiDeclarationError(
28
+ f"yapi route handler '{func.__name__}' must return a Pydantic BaseModel subclass"
29
+ )
30
+
31
+ request_model: type[BaseModel] | None = None
32
+ dependency_params: list[tuple[str, inspect.Parameter]] = []
33
+
34
+ for name, param in signature.parameters.items():
35
+ annotation = param.annotation
36
+ default = param.default
37
+
38
+ if isinstance(default, params.Depends):
39
+ dependency_params.append((name, param))
40
+ continue
41
+
42
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
43
+ if request_model is not None:
44
+ raise YapiDeclarationError(
45
+ f"yapi route handler '{func.__name__}' may declare at most one Pydantic request model parameter"
46
+ )
47
+ request_model = annotation
48
+ continue
49
+
50
+ raise YapiDeclarationError(
51
+ f"yapi route handler '{func.__name__}' has parameter '{name}' that is neither a Pydantic model nor a Depends() dependency"
52
+ )
53
+
54
+ return request_model, return_annotation, dependency_params
55
+
56
+
57
+ class PromptRouter(APIRouter):
58
+ def __init__(self, agent_runner: Callable[..., dict] | None = None) -> None:
59
+ super().__init__()
60
+ self._runtime = Runtime(agent_runner=agent_runner or build_agent_runner())
61
+
62
+ def _register(self, method: str, path: str) -> Callable[[Callable], Callable]:
63
+ upper = method.upper()
64
+ if upper not in _HTTP_METHODS:
65
+ raise YapiDeclarationError(f"Unsupported HTTP method: {method}")
66
+
67
+ def decorator(func: Callable) -> Callable:
68
+ request_model, response_model, dependency_params = _introspect(func)
69
+ endpoint = PromptEndpoint(
70
+ path=path,
71
+ method=upper,
72
+ request_model=request_model,
73
+ response_model=response_model,
74
+ function_doc=(func.__doc__ or "").strip(),
75
+ )
76
+
77
+ handler_params: list[inspect.Parameter] = []
78
+ request_param_name: str | None = None
79
+ for name, param in inspect.signature(func).parameters.items():
80
+ handler_params.append(param)
81
+ if isinstance(param.default, params.Depends):
82
+ continue
83
+ if isinstance(param.annotation, type) and issubclass(param.annotation, BaseModel):
84
+ request_param_name = name
85
+
86
+ async def handler(**kwargs):
87
+ injected = {
88
+ name: kwargs[name] for name, _ in dependency_params if name in kwargs
89
+ }
90
+ request_instance = (
91
+ kwargs.get(request_param_name) if request_param_name is not None else None
92
+ )
93
+
94
+ dynamic_prompt = func(**kwargs)
95
+ if dynamic_prompt is not None and not isinstance(dynamic_prompt, str):
96
+ raise RuntimeExecutionError(
97
+ f"yapi route handler '{func.__name__}' must return None or str, "
98
+ f"got {type(dynamic_prompt).__name__}"
99
+ )
100
+
101
+ return await run_in_threadpool(
102
+ self._runtime.execute,
103
+ endpoint=endpoint,
104
+ request_model=request_instance,
105
+ injected=injected,
106
+ dynamic_prompt=dynamic_prompt,
107
+ )
108
+
109
+ handler.__signature__ = inspect.Signature(parameters=handler_params, return_annotation=response_model)
110
+ handler.__annotations__ = {p.name: p.annotation for p in handler_params if p.annotation is not inspect.Parameter.empty}
111
+ handler.__annotations__["return"] = response_model
112
+ handler.__name__ = func.__name__
113
+
114
+ self.add_api_route(
115
+ path,
116
+ handler,
117
+ methods=[upper],
118
+ response_model=response_model,
119
+ )
120
+ return func
121
+
122
+ return decorator
123
+
124
+ def get(self, path: str, **_unused):
125
+ return self._register("GET", path)
126
+
127
+ def post(self, path: str, **_unused):
128
+ return self._register("POST", path)
129
+
130
+ def put(self, path: str, **_unused):
131
+ return self._register("PUT", path)
132
+
133
+ def patch(self, path: str, **_unused):
134
+ return self._register("PATCH", path)
135
+
136
+ def delete(self, path: str, **_unused):
137
+ return self._register("DELETE", path)
yapi/runtime.py ADDED
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from yapi.endpoint import PromptEndpoint
8
+ from yapi.errors import RuntimeExecutionError
9
+ from yapi.models import RuntimeContext
10
+
11
+ DEFAULT_SYSTEM_PREFIX = (
12
+ "You are the execution engine behind a declarative HTTP endpoint. "
13
+ "Return data that strictly matches the required response model."
14
+ )
15
+
16
+
17
+ def compose_prompt(endpoint: PromptEndpoint, dynamic_prompt: str | None) -> str:
18
+ sections = [DEFAULT_SYSTEM_PREFIX]
19
+ response_doc = endpoint.response_doc
20
+ if response_doc:
21
+ sections.append(response_doc)
22
+ if endpoint.function_doc:
23
+ sections.append(endpoint.function_doc)
24
+ if dynamic_prompt:
25
+ sections.append(dynamic_prompt)
26
+ return "\n\n".join(sections)
27
+
28
+
29
+ class Runtime:
30
+ def __init__(self, agent_runner: Callable[..., dict]) -> None:
31
+ self._agent_runner = agent_runner
32
+
33
+ def build_context(self, request_data: dict, injected: dict) -> RuntimeContext:
34
+ return RuntimeContext(
35
+ request=dict(request_data),
36
+ injected=dict(injected),
37
+ )
38
+
39
+ def execute(
40
+ self,
41
+ endpoint: PromptEndpoint,
42
+ request_model: BaseModel | None,
43
+ injected: dict,
44
+ dynamic_prompt: str | None,
45
+ ) -> BaseModel:
46
+ request_data = {} if request_model is None else request_model.model_dump()
47
+ context = self.build_context(request_data=request_data, injected=injected)
48
+ prompt = compose_prompt(endpoint, dynamic_prompt)
49
+
50
+ try:
51
+ payload = self._agent_runner(
52
+ prompt=prompt,
53
+ request=context.request,
54
+ injected=context.injected,
55
+ response_model=endpoint.response_model,
56
+ )
57
+ except Exception as exc:
58
+ raise RuntimeExecutionError("Agent execution failed") from exc
59
+
60
+ return endpoint.response_model.model_validate(payload)