pyyapi 0.1.0__tar.gz
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.
- pyyapi-0.1.0/LICENSE +21 -0
- pyyapi-0.1.0/PKG-INFO +153 -0
- pyyapi-0.1.0/README.md +118 -0
- pyyapi-0.1.0/pyproject.toml +55 -0
- pyyapi-0.1.0/pyyapi.egg-info/PKG-INFO +153 -0
- pyyapi-0.1.0/pyyapi.egg-info/SOURCES.txt +19 -0
- pyyapi-0.1.0/pyyapi.egg-info/dependency_links.txt +1 -0
- pyyapi-0.1.0/pyyapi.egg-info/requires.txt +9 -0
- pyyapi-0.1.0/pyyapi.egg-info/top_level.txt +1 -0
- pyyapi-0.1.0/setup.cfg +4 -0
- pyyapi-0.1.0/tests/test_exports.py +5 -0
- pyyapi-0.1.0/tests/test_integration.py +89 -0
- pyyapi-0.1.0/tests/test_router.py +106 -0
- pyyapi-0.1.0/tests/test_runtime.py +97 -0
- pyyapi-0.1.0/yapi/__init__.py +3 -0
- pyyapi-0.1.0/yapi/agent.py +42 -0
- pyyapi-0.1.0/yapi/endpoint.py +18 -0
- pyyapi-0.1.0/yapi/errors.py +14 -0
- pyyapi-0.1.0/yapi/models.py +9 -0
- pyyapi-0.1.0/yapi/router.py +137 -0
- pyyapi-0.1.0/yapi/runtime.py +60 -0
pyyapi-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pyyapi-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/pyyapi/)
|
|
39
|
+
[](https://pypi.org/project/pyyapi/)
|
|
40
|
+
[](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
|
pyyapi-0.1.0/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# yapi
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pyyapi/)
|
|
4
|
+
[](https://pypi.org/project/pyyapi/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
**Prompt-first declarative HTTP framework** — write a normal Python function with a docstring, get an LLM-powered HTTP endpoint with structured JSON responses.
|
|
8
|
+
|
|
9
|
+
`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.
|
|
10
|
+
|
|
11
|
+
> Package name on PyPI is `pyyapi` (the unhyphenated `yapi` was taken by a 2018 project). Import path is still `yapi`.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install pyyapi
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Python 3.12+ required.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from fastapi import FastAPI
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
|
|
27
|
+
from yapi import PromptRouter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WishIn(BaseModel):
|
|
31
|
+
user_id: str
|
|
32
|
+
wish: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WishOut(BaseModel):
|
|
36
|
+
"""You are a wish-granting entity. Decide whether to grant the wish."""
|
|
37
|
+
|
|
38
|
+
granted: bool
|
|
39
|
+
message: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
app = FastAPI(title="yapi showcase")
|
|
43
|
+
router = PromptRouter()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.post("/wish")
|
|
47
|
+
def make_a_wish(req: WishIn) -> WishOut:
|
|
48
|
+
"""Decide whether to grant the user's wish."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
app.include_router(router)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Run it:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
YAPI_MODEL=test uvicorn examples.wish_api:app --reload
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`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`.
|
|
61
|
+
|
|
62
|
+
Open `http://localhost:8000/docs` for the auto-generated OpenAPI UI.
|
|
63
|
+
|
|
64
|
+
## How it works
|
|
65
|
+
|
|
66
|
+
For each request, `yapi`:
|
|
67
|
+
|
|
68
|
+
1. parses the request body into the `BaseModel` you declared as a parameter,
|
|
69
|
+
2. calls your function synchronously to optionally produce a **dynamic prompt** (the function's `return` value, must be `None` or `str`),
|
|
70
|
+
3. composes the final system prompt from: response-model docstring + function docstring + dynamic prompt,
|
|
71
|
+
4. invokes the configured `agent_runner` (defaulting to a PydanticAI `Agent`) with the prompt + request payload,
|
|
72
|
+
5. validates the agent's output against your return annotation and serializes via FastAPI.
|
|
73
|
+
|
|
74
|
+
## Contract (hard rules)
|
|
75
|
+
|
|
76
|
+
- `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.
|
|
77
|
+
- The decorator only accepts the path argument. FastAPI kwargs like `tags=`, `summary=`, `status_code=`, `response_class=`, `response_model=` are silently ignored.
|
|
78
|
+
- Return annotation **must** be a `BaseModel` subclass.
|
|
79
|
+
- At most one parameter may be a `BaseModel` (the request body). Other parameters must have `default=fastapi.Depends(...)`.
|
|
80
|
+
- Function body must `return` either `None` or a `str` (the dynamic prompt). Anything else raises at request time. `async def` is not supported.
|
|
81
|
+
|
|
82
|
+
Violations are raised as `YapiDeclarationError` at decoration time — broken routes fail at import, not at request time.
|
|
83
|
+
|
|
84
|
+
## Dependency injection
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from fastapi import Depends
|
|
88
|
+
|
|
89
|
+
def get_db():
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
@router.post("/wish")
|
|
93
|
+
def make_a_wish(req: WishIn, db = Depends(get_db)) -> WishOut:
|
|
94
|
+
"""..."""
|
|
95
|
+
return f"user has {db.balance(req.user_id)} wishes left"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Custom agent runner
|
|
99
|
+
|
|
100
|
+
`PromptRouter(agent_runner=...)` accepts any callable with the signature `(*, prompt, request, injected, response_model) -> dict`. Useful for tests:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
router = PromptRouter(
|
|
104
|
+
agent_runner=lambda **_: {"granted": True, "message": "ok"},
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
uv sync --extra dev
|
|
112
|
+
uv run pytest
|
|
113
|
+
uv run uvicorn examples.wish_api:app --reload
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyyapi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Prompt-first declarative HTTP framework on top of FastAPI and PydanticAI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "DJJ", email = "shuaiqijianhao@qq.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["fastapi", "pydantic", "pydantic-ai", "llm", "prompt", "http", "framework", "declarative"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Framework :: FastAPI",
|
|
25
|
+
"Framework :: Pydantic",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"fastapi>=0.115,<1",
|
|
32
|
+
"pydantic>=2.7,<3",
|
|
33
|
+
"pydantic-ai>=0.0.18,<1",
|
|
34
|
+
"uvicorn>=0.30,<1",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"httpx>=0.27,<1",
|
|
40
|
+
"pytest>=8.2,<9",
|
|
41
|
+
"pytest-asyncio>=0.23,<1",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/TokenRollAI/yapi"
|
|
46
|
+
Repository = "https://github.com/TokenRollAI/yapi"
|
|
47
|
+
Issues = "https://github.com/TokenRollAI/yapi/issues"
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
include = ["yapi*"]
|
|
51
|
+
exclude = ["tests*", "examples*", "docs*", "llmdoc*"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
pythonpath = ["."]
|
|
55
|
+
testpaths = ["tests"]
|
|
@@ -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
|
+
[](https://pypi.org/project/pyyapi/)
|
|
39
|
+
[](https://pypi.org/project/pyyapi/)
|
|
40
|
+
[](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,19 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
pyyapi.egg-info/PKG-INFO
|
|
5
|
+
pyyapi.egg-info/SOURCES.txt
|
|
6
|
+
pyyapi.egg-info/dependency_links.txt
|
|
7
|
+
pyyapi.egg-info/requires.txt
|
|
8
|
+
pyyapi.egg-info/top_level.txt
|
|
9
|
+
tests/test_exports.py
|
|
10
|
+
tests/test_integration.py
|
|
11
|
+
tests/test_router.py
|
|
12
|
+
tests/test_runtime.py
|
|
13
|
+
yapi/__init__.py
|
|
14
|
+
yapi/agent.py
|
|
15
|
+
yapi/endpoint.py
|
|
16
|
+
yapi/errors.py
|
|
17
|
+
yapi/models.py
|
|
18
|
+
yapi/router.py
|
|
19
|
+
yapi/runtime.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
yapi
|
pyyapi-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from fastapi import Depends, FastAPI
|
|
2
|
+
from fastapi.testclient import TestClient
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from yapi import PromptRouter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WishRequest(BaseModel):
|
|
9
|
+
user_id: str
|
|
10
|
+
wish: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WishResponse(BaseModel):
|
|
14
|
+
"""你是一个愿望受理实体。"""
|
|
15
|
+
|
|
16
|
+
granted: bool
|
|
17
|
+
message: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_dependency_injection_flows_into_runtime() -> None:
|
|
21
|
+
app = FastAPI()
|
|
22
|
+
|
|
23
|
+
def fake_agent_runner(**kwargs):
|
|
24
|
+
injected = kwargs["injected"]
|
|
25
|
+
return {
|
|
26
|
+
"granted": injected["profile"]["vip"],
|
|
27
|
+
"message": "vip granted",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
router = PromptRouter(agent_runner=fake_agent_runner)
|
|
31
|
+
|
|
32
|
+
def fetch_profile(req: WishRequest) -> dict:
|
|
33
|
+
return {"vip": req.user_id == "u-1"}
|
|
34
|
+
|
|
35
|
+
@router.post("/wish")
|
|
36
|
+
def make_a_wish(
|
|
37
|
+
req: WishRequest,
|
|
38
|
+
profile: dict = Depends(fetch_profile),
|
|
39
|
+
) -> WishResponse:
|
|
40
|
+
"""grant wishes"""
|
|
41
|
+
|
|
42
|
+
app.include_router(router)
|
|
43
|
+
client = TestClient(app)
|
|
44
|
+
|
|
45
|
+
response = client.post("/wish", json={"user_id": "u-1", "wish": "moon"})
|
|
46
|
+
|
|
47
|
+
assert response.status_code == 200
|
|
48
|
+
assert response.json() == {"granted": True, "message": "vip granted"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_dynamic_prompt_returned_by_handler_reaches_agent_runner() -> None:
|
|
52
|
+
captured = {}
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
|
|
55
|
+
def fake_agent_runner(**kwargs):
|
|
56
|
+
captured.update(kwargs)
|
|
57
|
+
return {"granted": True, "message": "ok"}
|
|
58
|
+
|
|
59
|
+
router = PromptRouter(agent_runner=fake_agent_runner)
|
|
60
|
+
|
|
61
|
+
@router.post("/wish")
|
|
62
|
+
def make_a_wish(req: WishRequest) -> WishResponse:
|
|
63
|
+
"""grant wishes"""
|
|
64
|
+
return f"focus on user {req.user_id}'s mood: {req.wish}"
|
|
65
|
+
|
|
66
|
+
app.include_router(router)
|
|
67
|
+
client = TestClient(app)
|
|
68
|
+
|
|
69
|
+
response = client.post("/wish", json={"user_id": "u-1", "wish": "stars"})
|
|
70
|
+
|
|
71
|
+
assert response.status_code == 200
|
|
72
|
+
assert "focus on user u-1's mood: stars" in captured["prompt"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_handler_returning_non_string_raises_runtime_error() -> None:
|
|
76
|
+
app = FastAPI()
|
|
77
|
+
router = PromptRouter(agent_runner=lambda **_: {"granted": True, "message": "ok"})
|
|
78
|
+
|
|
79
|
+
@router.post("/wish")
|
|
80
|
+
def make_a_wish(req: WishRequest) -> WishResponse:
|
|
81
|
+
"""grant wishes"""
|
|
82
|
+
return 42
|
|
83
|
+
|
|
84
|
+
app.include_router(router)
|
|
85
|
+
client = TestClient(app, raise_server_exceptions=False)
|
|
86
|
+
|
|
87
|
+
response = client.post("/wish", json={"user_id": "u-1", "wish": "moon"})
|
|
88
|
+
|
|
89
|
+
assert response.status_code == 500
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fastapi import FastAPI
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from yapi import PromptRouter
|
|
6
|
+
from yapi.errors import YapiDeclarationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WishRequest(BaseModel):
|
|
10
|
+
user_id: str
|
|
11
|
+
wish: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WishResponse(BaseModel):
|
|
15
|
+
"""你是一个愿望受理实体。"""
|
|
16
|
+
|
|
17
|
+
granted: bool
|
|
18
|
+
message: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_router_post_registers_route_with_inferred_models() -> None:
|
|
22
|
+
app = FastAPI()
|
|
23
|
+
router = PromptRouter(agent_runner=lambda **_: {"granted": True, "message": "ok"})
|
|
24
|
+
|
|
25
|
+
@router.post("/wish")
|
|
26
|
+
def make_a_wish(req: WishRequest) -> WishResponse:
|
|
27
|
+
"""grant wishes"""
|
|
28
|
+
|
|
29
|
+
app.include_router(router)
|
|
30
|
+
|
|
31
|
+
paths = {(route.path, tuple(route.methods)) for route in app.routes}
|
|
32
|
+
assert ("/wish", ("POST",)) in paths
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_router_post_emits_openapi_with_declared_models() -> None:
|
|
36
|
+
app = FastAPI()
|
|
37
|
+
router = PromptRouter(agent_runner=lambda **_: {"granted": True, "message": "ok"})
|
|
38
|
+
|
|
39
|
+
@router.post("/wish")
|
|
40
|
+
def make_a_wish(req: WishRequest) -> WishResponse:
|
|
41
|
+
"""grant wishes"""
|
|
42
|
+
|
|
43
|
+
app.include_router(router)
|
|
44
|
+
schema = app.openapi()
|
|
45
|
+
|
|
46
|
+
operation = schema["paths"]["/wish"]["post"]
|
|
47
|
+
assert operation["requestBody"] is not None
|
|
48
|
+
assert operation["responses"]["200"] is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_router_post_requires_response_annotation() -> None:
|
|
52
|
+
router = PromptRouter(agent_runner=lambda **_: {"granted": True, "message": "ok"})
|
|
53
|
+
|
|
54
|
+
with pytest.raises(YapiDeclarationError):
|
|
55
|
+
|
|
56
|
+
@router.post("/wish")
|
|
57
|
+
def make_a_wish(req: WishRequest):
|
|
58
|
+
"""grant wishes"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_router_post_rejects_non_basemodel_response() -> None:
|
|
62
|
+
router = PromptRouter(agent_runner=lambda **_: {"granted": True, "message": "ok"})
|
|
63
|
+
|
|
64
|
+
with pytest.raises(YapiDeclarationError):
|
|
65
|
+
|
|
66
|
+
@router.post("/wish")
|
|
67
|
+
def make_a_wish(req: WishRequest) -> dict:
|
|
68
|
+
"""grant wishes"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_router_post_rejects_multiple_basemodel_params() -> None:
|
|
72
|
+
class Extra(BaseModel):
|
|
73
|
+
flag: bool
|
|
74
|
+
|
|
75
|
+
router = PromptRouter(agent_runner=lambda **_: {"granted": True, "message": "ok"})
|
|
76
|
+
|
|
77
|
+
with pytest.raises(YapiDeclarationError):
|
|
78
|
+
|
|
79
|
+
@router.post("/wish")
|
|
80
|
+
def make_a_wish(req: WishRequest, extra: Extra) -> WishResponse:
|
|
81
|
+
"""grant wishes"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_router_supports_get_with_no_request_body() -> None:
|
|
85
|
+
app = FastAPI()
|
|
86
|
+
router = PromptRouter(agent_runner=lambda **_: {"message": "ok"})
|
|
87
|
+
|
|
88
|
+
class StatusOut(BaseModel):
|
|
89
|
+
"""describe the system."""
|
|
90
|
+
|
|
91
|
+
message: str
|
|
92
|
+
|
|
93
|
+
@router.get("/status")
|
|
94
|
+
def status() -> StatusOut:
|
|
95
|
+
"""return current status."""
|
|
96
|
+
|
|
97
|
+
app.include_router(router)
|
|
98
|
+
|
|
99
|
+
paths = {(route.path, tuple(route.methods)) for route in app.routes}
|
|
100
|
+
assert ("/status", ("GET",)) in paths
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_example_application_imports() -> None:
|
|
104
|
+
from examples.wish_api import app
|
|
105
|
+
|
|
106
|
+
assert app is not None
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
from yapi.endpoint import PromptEndpoint
|
|
4
|
+
from yapi.models import RuntimeContext
|
|
5
|
+
from yapi.runtime import Runtime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WishRequest(BaseModel):
|
|
9
|
+
user_id: str
|
|
10
|
+
wish: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WishResponse(BaseModel):
|
|
14
|
+
"""你是一个愿望受理实体。"""
|
|
15
|
+
|
|
16
|
+
granted: bool
|
|
17
|
+
message: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_prompt_endpoint_stores_definition() -> None:
|
|
21
|
+
endpoint = PromptEndpoint(
|
|
22
|
+
path="/wish",
|
|
23
|
+
method="POST",
|
|
24
|
+
request_model=WishRequest,
|
|
25
|
+
response_model=WishResponse,
|
|
26
|
+
function_doc="grant wishes",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
assert endpoint.path == "/wish"
|
|
30
|
+
assert endpoint.method == "POST"
|
|
31
|
+
assert endpoint.request_model is WishRequest
|
|
32
|
+
assert endpoint.response_model is WishResponse
|
|
33
|
+
assert endpoint.function_doc == "grant wishes"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_runtime_builds_context_sections() -> None:
|
|
37
|
+
runtime = Runtime(agent_runner=lambda **_: {"granted": True, "message": "ok"})
|
|
38
|
+
|
|
39
|
+
context = runtime.build_context(
|
|
40
|
+
request_data={"user_id": "u-1", "wish": "moon"},
|
|
41
|
+
injected={"profile": {"vip": True}},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
assert context.request == {"user_id": "u-1", "wish": "moon"}
|
|
45
|
+
assert context.injected == {"profile": {"vip": True}}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_runtime_executes_agent_and_returns_response_model() -> None:
|
|
49
|
+
endpoint = PromptEndpoint(
|
|
50
|
+
path="/wish",
|
|
51
|
+
method="POST",
|
|
52
|
+
request_model=WishRequest,
|
|
53
|
+
response_model=WishResponse,
|
|
54
|
+
function_doc="grant wishes",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
runtime = Runtime(
|
|
58
|
+
agent_runner=lambda **_: {"granted": True, "message": "granted"},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
response = runtime.execute(
|
|
62
|
+
endpoint=endpoint,
|
|
63
|
+
request_model=WishRequest(user_id="u-1", wish="moon"),
|
|
64
|
+
injected={"profile": {"vip": True}},
|
|
65
|
+
dynamic_prompt=None,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assert response.model_dump() == {"granted": True, "message": "granted"}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_runtime_sends_composed_prompt_to_agent_runner() -> None:
|
|
72
|
+
captured = {}
|
|
73
|
+
endpoint = PromptEndpoint(
|
|
74
|
+
path="/wish",
|
|
75
|
+
method="POST",
|
|
76
|
+
request_model=WishRequest,
|
|
77
|
+
response_model=WishResponse,
|
|
78
|
+
function_doc="grant wishes based on context",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def fake_agent_runner(**kwargs):
|
|
82
|
+
captured.update(kwargs)
|
|
83
|
+
return {"granted": True, "message": "ok"}
|
|
84
|
+
|
|
85
|
+
runtime = Runtime(agent_runner=fake_agent_runner)
|
|
86
|
+
runtime.execute(
|
|
87
|
+
endpoint=endpoint,
|
|
88
|
+
request_model=WishRequest(user_id="u-1", wish="moon"),
|
|
89
|
+
injected={"profile": {"vip": True}},
|
|
90
|
+
dynamic_prompt="user is shouting",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert "你是一个愿望受理实体。" in captured["prompt"]
|
|
94
|
+
assert "grant wishes based on context" in captured["prompt"]
|
|
95
|
+
assert "user is shouting" in captured["prompt"]
|
|
96
|
+
assert captured["request"] == {"user_id": "u-1", "wish": "moon"}
|
|
97
|
+
assert captured["injected"] == {"profile": {"vip": True}}
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -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)
|
|
@@ -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)
|