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 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
+ [![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
pyyapi-0.1.0/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # yapi
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/pyyapi.svg)](https://pypi.org/project/pyyapi/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/pyyapi.svg)](https://pypi.org/project/pyyapi/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ [![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,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,9 @@
1
+ fastapi<1,>=0.115
2
+ pydantic<3,>=2.7
3
+ pydantic-ai<1,>=0.0.18
4
+ uvicorn<1,>=0.30
5
+
6
+ [dev]
7
+ httpx<1,>=0.27
8
+ pytest<9,>=8.2
9
+ pytest-asyncio<1,>=0.23
@@ -0,0 +1 @@
1
+ yapi
pyyapi-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from yapi import PromptRouter
2
+
3
+
4
+ def test_public_exports_are_available() -> None:
5
+ assert PromptRouter is not None
@@ -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,3 @@
1
+ from yapi.router import PromptRouter
2
+
3
+ __all__ = ["PromptRouter"]
@@ -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,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
@@ -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
@@ -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)