pyyapi 0.1.0__tar.gz → 0.3.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.
Files changed (37) hide show
  1. pyyapi-0.3.0/PKG-INFO +292 -0
  2. pyyapi-0.3.0/README.md +257 -0
  3. {pyyapi-0.1.0 → pyyapi-0.3.0}/pyproject.toml +4 -1
  4. pyyapi-0.3.0/pyyapi.egg-info/PKG-INFO +292 -0
  5. {pyyapi-0.1.0 → pyyapi-0.3.0}/pyyapi.egg-info/SOURCES.txt +7 -0
  6. pyyapi-0.3.0/tests/test_compat.py +111 -0
  7. pyyapi-0.3.0/tests/test_dx.py +155 -0
  8. pyyapi-0.3.0/tests/test_integration.py +171 -0
  9. pyyapi-0.3.0/tests/test_prompt_context.py +89 -0
  10. pyyapi-0.3.0/tests/test_router.py +548 -0
  11. pyyapi-0.3.0/tests/test_runner.py +145 -0
  12. {pyyapi-0.1.0 → pyyapi-0.3.0}/tests/test_runtime.py +57 -0
  13. pyyapi-0.3.0/yapi/__init__.py +20 -0
  14. pyyapi-0.3.0/yapi/agent.py +49 -0
  15. {pyyapi-0.1.0 → pyyapi-0.3.0}/yapi/errors.py +2 -2
  16. pyyapi-0.3.0/yapi/prompt_context.py +39 -0
  17. pyyapi-0.3.0/yapi/py.typed +0 -0
  18. pyyapi-0.3.0/yapi/router.py +368 -0
  19. pyyapi-0.3.0/yapi/runner.py +49 -0
  20. pyyapi-0.3.0/yapi/runtime.py +129 -0
  21. pyyapi-0.1.0/PKG-INFO +0 -153
  22. pyyapi-0.1.0/README.md +0 -118
  23. pyyapi-0.1.0/pyyapi.egg-info/PKG-INFO +0 -153
  24. pyyapi-0.1.0/tests/test_integration.py +0 -89
  25. pyyapi-0.1.0/tests/test_router.py +0 -106
  26. pyyapi-0.1.0/yapi/__init__.py +0 -3
  27. pyyapi-0.1.0/yapi/agent.py +0 -42
  28. pyyapi-0.1.0/yapi/router.py +0 -137
  29. pyyapi-0.1.0/yapi/runtime.py +0 -60
  30. {pyyapi-0.1.0 → pyyapi-0.3.0}/LICENSE +0 -0
  31. {pyyapi-0.1.0 → pyyapi-0.3.0}/pyyapi.egg-info/dependency_links.txt +0 -0
  32. {pyyapi-0.1.0 → pyyapi-0.3.0}/pyyapi.egg-info/requires.txt +0 -0
  33. {pyyapi-0.1.0 → pyyapi-0.3.0}/pyyapi.egg-info/top_level.txt +0 -0
  34. {pyyapi-0.1.0 → pyyapi-0.3.0}/setup.cfg +0 -0
  35. {pyyapi-0.1.0 → pyyapi-0.3.0}/tests/test_exports.py +0 -0
  36. {pyyapi-0.1.0 → pyyapi-0.3.0}/yapi/endpoint.py +0 -0
  37. {pyyapi-0.1.0 → pyyapi-0.3.0}/yapi/models.py +0 -0
pyyapi-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,292 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyyapi
3
+ Version: 0.3.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
+ > 中文文档请见 [README.zh-CN.md](./README.zh-CN.md)
43
+
44
+ **Prompt-first declarative HTTP framework** — write a normal Python function with a docstring, get an LLM-powered HTTP endpoint with structured JSON responses.
45
+
46
+ `yapi` is a thin layer on top of [FastAPI](https://fastapi.tiangolo.com/) and [PydanticAI](https://ai.pydantic.dev/). `PromptRouter` is a true *superset* of `fastapi.APIRouter`: native routes work as-is, and prompt routes live in the `router.prompt.*` namespace.
47
+
48
+ > Package name on PyPI is `pyyapi` (the unhyphenated `yapi` was taken by a 2018 project). Import path is still `yapi`.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install pyyapi
54
+ ```
55
+
56
+ Python 3.12+ required.
57
+
58
+ ## Quick start
59
+
60
+ ```python
61
+ from fastapi import FastAPI
62
+ from pydantic import BaseModel
63
+
64
+ from yapi import PromptRouter
65
+
66
+
67
+ class WishIn(BaseModel):
68
+ user_id: str
69
+ wish: str
70
+
71
+
72
+ class WishOut(BaseModel):
73
+ """You are a wish-granting entity. Decide whether to grant the wish."""
74
+
75
+ granted: bool
76
+ message: str
77
+
78
+
79
+ app = FastAPI(title="yapi showcase")
80
+ router = PromptRouter()
81
+
82
+
83
+ @router.prompt.post("/wish")
84
+ def make_a_wish(req: WishIn) -> WishOut:
85
+ """Decide whether to grant the user's wish."""
86
+
87
+
88
+ app.include_router(router)
89
+ ```
90
+
91
+ Run it:
92
+
93
+ ```bash
94
+ YAPI_MODEL=test uvicorn examples.wish_api:app --reload
95
+ ```
96
+
97
+ `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`.
98
+
99
+ Open `http://localhost:8000/docs` for the auto-generated OpenAPI UI.
100
+
101
+ ## Mixing native FastAPI routes with prompt routes
102
+
103
+ `PromptRouter` is now a real `APIRouter` superset. `.get/.post/...` keep their FastAPI semantics; only `router.prompt.*` enters the LLM pipeline.
104
+
105
+ ```python
106
+ router = PromptRouter(prefix="/v1", tags=["wishes"])
107
+
108
+
109
+ @router.get("/health")
110
+ def health() -> dict:
111
+ return {"status": "ok"}
112
+
113
+
114
+ @router.prompt.post("/wish")
115
+ def make_a_wish(req: WishIn) -> WishOut:
116
+ """Decide whether to grant the user's wish."""
117
+ ```
118
+
119
+ ## Configuration
120
+
121
+ `yapi` is configured entirely through environment variables — the package never reads `.env` files itself. Use a launcher that injects them (recommended: `uvicorn --env-file .env`; alternatives: `set -a; source .env; set +a` in your shell, Docker `--env-file`, Kubernetes secrets, etc.).
122
+
123
+ ### `YAPI_MODEL` (required for the default runner)
124
+
125
+ PydanticAI model string in `provider:model` form. Read once when `PromptRouter()` is constructed without an explicit `agent_runner`.
126
+
127
+ ```bash
128
+ YAPI_MODEL=openai:gpt-4o # OpenAI
129
+ YAPI_MODEL=anthropic:claude-3-5-sonnet # Anthropic
130
+ YAPI_MODEL=openai:deepseek-chat # DeepSeek (OpenAI-compatible)
131
+ YAPI_MODEL=test # PydanticAI TestModel, no key, no network
132
+ ```
133
+
134
+ Unset → constructor emits a `YapiUsageWarning`, first request returns HTTP 500.
135
+
136
+ > ⚠️ **The model must support OpenAI Function Calling's `tool_choice` parameter.** `yapi` relies on PydanticAI's structured-output path, which forces the model to emit a tool call matching your response `BaseModel`. Models that lack `tool_choice` support — most notably "reasoning / thinking" variants such as `deepseek-reasoner`, `deepseek-v4-flash`, `o1-preview` / `o1-mini`, or any chat-only / completion-only checkpoint — will return HTTP 500 with a `ModelHTTPError` at the first request. Pick a model whose API docs explicitly support function calling (`gpt-4o`, `gpt-4o-mini`, `claude-3-5-sonnet`, `deepseek-chat`, …).
137
+
138
+ ### Provider credentials (read directly by PydanticAI)
139
+
140
+ `yapi` does **not** validate or even look at these — they are consumed by the underlying PydanticAI provider via `os.environ`:
141
+
142
+ | Provider | Env vars |
143
+ |---|---|
144
+ | OpenAI | `OPENAI_API_KEY` |
145
+ | OpenAI-compatible endpoints (DeepSeek, Azure OpenAI, OneAPI, local servers, …) | `OPENAI_API_KEY` + `OPENAI_BASE_URL` (e.g. `https://api.deepseek.com/v1`) |
146
+ | Anthropic | `ANTHROPIC_API_KEY` |
147
+ | Others (Google, Groq, Mistral, …) | See [PydanticAI providers docs](https://ai.pydantic.dev/models/) |
148
+
149
+ ### Example `.env` (DeepSeek)
150
+
151
+ ```dotenv
152
+ YAPI_MODEL=openai:deepseek-chat
153
+ OPENAI_API_KEY=sk-...
154
+ OPENAI_BASE_URL=https://api.deepseek.com/v1
155
+ ```
156
+
157
+ ```bash
158
+ uv run uvicorn examples.wish_api:app --reload --env-file .env
159
+ ```
160
+
161
+ > Same caveat as the warning above: DeepSeek's "thinking" models (`deepseek-reasoner`, `deepseek-v4-flash`) reject `tool_choice` and won't work here. Use `deepseek-chat`.
162
+
163
+ ## How a prompt route runs
164
+
165
+ For each request to a `router.prompt.*` route, `yapi`:
166
+
167
+ 1. parses path/query/header/cookie/body parameters via the function signature (FastAPI semantics, plus a single `BaseModel` request body),
168
+ 2. calls your function (sync or `async def`) to optionally produce a **dynamic prompt** (the function's `return` value, must be `None` or `str`),
169
+ 3. composes the final system prompt from: response-model docstring + function docstring + dynamic prompt,
170
+ 4. invokes the configured `agent_runner` (defaulting to a PydanticAI `Agent`) with a `RunnerContext` containing the prompt, request payload, injected fields, response model, path and method,
171
+ 5. validates the agent's output against your return annotation and serializes via FastAPI.
172
+
173
+ ## Contract (hard rules)
174
+
175
+ Applies inside `router.prompt.*`:
176
+
177
+ - Return annotation **must** be a `BaseModel` subclass.
178
+ - At most one parameter may be a `BaseModel` (the request body). Supports both `req: WishIn` and `req: Annotated[WishIn, Body()]`.
179
+ - Other parameters must be one of:
180
+ - `Depends(...)` default or `Annotated[T, Depends(...)]`
181
+ - `Annotated[T, Query()/Header()/Cookie()/Path()/Form()/File()]` or the equivalent `= Query(...)` default
182
+ - `*args` / `**kwargs` are rejected at decoration time.
183
+ - Function body must `return` `None` or a `str` (the dynamic prompt). Anything else raises at request time.
184
+ - `async def` is supported.
185
+
186
+ Decoration kwargs:
187
+
188
+ - Passed through to FastAPI: `tags`, `summary`, `description`, `status_code`, `deprecated`, `operation_id`, `name`, `include_in_schema`, `responses`, `openapi_extra`.
189
+ - Rejected at decoration time with `YapiDeclarationError`: `response_model`, `response_class`, `dependencies`.
190
+ - Any other unknown kwarg emits a `YapiUsageWarning`.
191
+
192
+ Violations are raised as `YapiDeclarationError` at decoration time — broken routes fail at import, not at request time.
193
+
194
+ ## Prompt context
195
+
196
+ Use `PromptContext` to inject structured facts into the system prompt without returning a string. Declare a parameter typed `PromptContext` and `yapi` auto-injects a per-request instance:
197
+
198
+ ```python
199
+ from yapi import PromptContext, PromptRouter
200
+
201
+ router = PromptRouter()
202
+
203
+
204
+ @router.prompt.post("/wish")
205
+ def make_a_wish(req: WishIn, ctx: PromptContext) -> WishOut:
206
+ """Decide whether to grant the user's wish."""
207
+ ctx.add_section("User Profile", {"vip": req.user_id.startswith("vip-")})
208
+ ctx.add_kv("user_id", req.user_id)
209
+ ctx.add(req.wish)
210
+ ```
211
+
212
+ `yapi` collects all segments and wraps them in `<context>…</context>` at the end of the system prompt:
213
+
214
+ ```
215
+ You are the execution engine…
216
+
217
+ Decide whether to grant the user's wish.
218
+
219
+ <context>
220
+ # User Profile
221
+ {"vip": true}
222
+
223
+ user_id: vip-1
224
+
225
+ moon
226
+ </context>
227
+ ```
228
+
229
+ Three methods:
230
+
231
+ | Method | Produces |
232
+ |---|---|
233
+ | `ctx.add(value)` | `<serialized value>` |
234
+ | `ctx.add_kv(key, value)` | `{key}: <serialized value>` |
235
+ | `ctx.add_section(name, body)` | `# {name}\n<serialized body>` |
236
+
237
+ Value serialization: `str` → pass-through; `BaseModel` → `model_dump_json()`; `dict`/`list`/`tuple` → `json.dumps(..., ensure_ascii=False)`; anything else → `str()`. `None` is rejected — use `""` if you want an empty segment.
238
+
239
+ `PromptContext` is **append-only** — no `clear` / `pop`. Use Python `if` for conditional adds. At most one `PromptContext` parameter per route; the parameter must not carry FastAPI markers (`Annotated[PromptContext, Body()/Query()/Depends()]` is a declaration error).
240
+
241
+ State retrieval is out of scope for `yapi`. Fetch your data via `Depends(...)` and pass it to `ctx.*`. See `examples/state_via_depends.py`.
242
+
243
+ ## Dependency injection
244
+
245
+ ```python
246
+ from fastapi import Depends
247
+ from typing import Annotated
248
+
249
+ def get_db():
250
+ ...
251
+
252
+ @router.prompt.post("/wish")
253
+ def make_a_wish(
254
+ req: WishIn,
255
+ db: Annotated[Database, Depends(get_db)],
256
+ ) -> WishOut:
257
+ """..."""
258
+ return f"user has {db.balance(req.user_id)} wishes left"
259
+ ```
260
+
261
+ ## Custom agent runner
262
+
263
+ Implement the `AgentRunner` Protocol — any object with a `.run(ctx: RunnerContext) -> dict | BaseModel` method is accepted:
264
+
265
+ ```python
266
+ from yapi import AgentRunner, PromptRouter, RunnerContext
267
+
268
+ class MockRunner:
269
+ def run(self, ctx: RunnerContext) -> dict:
270
+ return {
271
+ "granted": "moon" not in ctx.request["wish"].lower(),
272
+ "message": f"path={ctx.path}",
273
+ }
274
+
275
+ router = PromptRouter(agent_runner=MockRunner())
276
+ ```
277
+
278
+ The legacy v2-style `(*, prompt, request, injected, response_model) -> dict` callable is still accepted (auto-adapted).
279
+
280
+ You can also inject a custom `prompt_composer=` to customize how the system prompt is assembled.
281
+
282
+ ## Development
283
+
284
+ ```bash
285
+ uv sync --extra dev
286
+ uv run pytest
287
+ uv run uvicorn examples.wish_api:app --reload
288
+ ```
289
+
290
+ ## License
291
+
292
+ MIT
pyyapi-0.3.0/README.md ADDED
@@ -0,0 +1,257 @@
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
+ > 中文文档请见 [README.zh-CN.md](./README.zh-CN.md)
8
+
9
+ **Prompt-first declarative HTTP framework** — write a normal Python function with a docstring, get an LLM-powered HTTP endpoint with structured JSON responses.
10
+
11
+ `yapi` is a thin layer on top of [FastAPI](https://fastapi.tiangolo.com/) and [PydanticAI](https://ai.pydantic.dev/). `PromptRouter` is a true *superset* of `fastapi.APIRouter`: native routes work as-is, and prompt routes live in the `router.prompt.*` namespace.
12
+
13
+ > Package name on PyPI is `pyyapi` (the unhyphenated `yapi` was taken by a 2018 project). Import path is still `yapi`.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install pyyapi
19
+ ```
20
+
21
+ Python 3.12+ required.
22
+
23
+ ## Quick start
24
+
25
+ ```python
26
+ from fastapi import FastAPI
27
+ from pydantic import BaseModel
28
+
29
+ from yapi import PromptRouter
30
+
31
+
32
+ class WishIn(BaseModel):
33
+ user_id: str
34
+ wish: str
35
+
36
+
37
+ class WishOut(BaseModel):
38
+ """You are a wish-granting entity. Decide whether to grant the wish."""
39
+
40
+ granted: bool
41
+ message: str
42
+
43
+
44
+ app = FastAPI(title="yapi showcase")
45
+ router = PromptRouter()
46
+
47
+
48
+ @router.prompt.post("/wish")
49
+ def make_a_wish(req: WishIn) -> WishOut:
50
+ """Decide whether to grant the user's wish."""
51
+
52
+
53
+ app.include_router(router)
54
+ ```
55
+
56
+ Run it:
57
+
58
+ ```bash
59
+ YAPI_MODEL=test uvicorn examples.wish_api:app --reload
60
+ ```
61
+
62
+ `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`.
63
+
64
+ Open `http://localhost:8000/docs` for the auto-generated OpenAPI UI.
65
+
66
+ ## Mixing native FastAPI routes with prompt routes
67
+
68
+ `PromptRouter` is now a real `APIRouter` superset. `.get/.post/...` keep their FastAPI semantics; only `router.prompt.*` enters the LLM pipeline.
69
+
70
+ ```python
71
+ router = PromptRouter(prefix="/v1", tags=["wishes"])
72
+
73
+
74
+ @router.get("/health")
75
+ def health() -> dict:
76
+ return {"status": "ok"}
77
+
78
+
79
+ @router.prompt.post("/wish")
80
+ def make_a_wish(req: WishIn) -> WishOut:
81
+ """Decide whether to grant the user's wish."""
82
+ ```
83
+
84
+ ## Configuration
85
+
86
+ `yapi` is configured entirely through environment variables — the package never reads `.env` files itself. Use a launcher that injects them (recommended: `uvicorn --env-file .env`; alternatives: `set -a; source .env; set +a` in your shell, Docker `--env-file`, Kubernetes secrets, etc.).
87
+
88
+ ### `YAPI_MODEL` (required for the default runner)
89
+
90
+ PydanticAI model string in `provider:model` form. Read once when `PromptRouter()` is constructed without an explicit `agent_runner`.
91
+
92
+ ```bash
93
+ YAPI_MODEL=openai:gpt-4o # OpenAI
94
+ YAPI_MODEL=anthropic:claude-3-5-sonnet # Anthropic
95
+ YAPI_MODEL=openai:deepseek-chat # DeepSeek (OpenAI-compatible)
96
+ YAPI_MODEL=test # PydanticAI TestModel, no key, no network
97
+ ```
98
+
99
+ Unset → constructor emits a `YapiUsageWarning`, first request returns HTTP 500.
100
+
101
+ > ⚠️ **The model must support OpenAI Function Calling's `tool_choice` parameter.** `yapi` relies on PydanticAI's structured-output path, which forces the model to emit a tool call matching your response `BaseModel`. Models that lack `tool_choice` support — most notably "reasoning / thinking" variants such as `deepseek-reasoner`, `deepseek-v4-flash`, `o1-preview` / `o1-mini`, or any chat-only / completion-only checkpoint — will return HTTP 500 with a `ModelHTTPError` at the first request. Pick a model whose API docs explicitly support function calling (`gpt-4o`, `gpt-4o-mini`, `claude-3-5-sonnet`, `deepseek-chat`, …).
102
+
103
+ ### Provider credentials (read directly by PydanticAI)
104
+
105
+ `yapi` does **not** validate or even look at these — they are consumed by the underlying PydanticAI provider via `os.environ`:
106
+
107
+ | Provider | Env vars |
108
+ |---|---|
109
+ | OpenAI | `OPENAI_API_KEY` |
110
+ | OpenAI-compatible endpoints (DeepSeek, Azure OpenAI, OneAPI, local servers, …) | `OPENAI_API_KEY` + `OPENAI_BASE_URL` (e.g. `https://api.deepseek.com/v1`) |
111
+ | Anthropic | `ANTHROPIC_API_KEY` |
112
+ | Others (Google, Groq, Mistral, …) | See [PydanticAI providers docs](https://ai.pydantic.dev/models/) |
113
+
114
+ ### Example `.env` (DeepSeek)
115
+
116
+ ```dotenv
117
+ YAPI_MODEL=openai:deepseek-chat
118
+ OPENAI_API_KEY=sk-...
119
+ OPENAI_BASE_URL=https://api.deepseek.com/v1
120
+ ```
121
+
122
+ ```bash
123
+ uv run uvicorn examples.wish_api:app --reload --env-file .env
124
+ ```
125
+
126
+ > Same caveat as the warning above: DeepSeek's "thinking" models (`deepseek-reasoner`, `deepseek-v4-flash`) reject `tool_choice` and won't work here. Use `deepseek-chat`.
127
+
128
+ ## How a prompt route runs
129
+
130
+ For each request to a `router.prompt.*` route, `yapi`:
131
+
132
+ 1. parses path/query/header/cookie/body parameters via the function signature (FastAPI semantics, plus a single `BaseModel` request body),
133
+ 2. calls your function (sync or `async def`) to optionally produce a **dynamic prompt** (the function's `return` value, must be `None` or `str`),
134
+ 3. composes the final system prompt from: response-model docstring + function docstring + dynamic prompt,
135
+ 4. invokes the configured `agent_runner` (defaulting to a PydanticAI `Agent`) with a `RunnerContext` containing the prompt, request payload, injected fields, response model, path and method,
136
+ 5. validates the agent's output against your return annotation and serializes via FastAPI.
137
+
138
+ ## Contract (hard rules)
139
+
140
+ Applies inside `router.prompt.*`:
141
+
142
+ - Return annotation **must** be a `BaseModel` subclass.
143
+ - At most one parameter may be a `BaseModel` (the request body). Supports both `req: WishIn` and `req: Annotated[WishIn, Body()]`.
144
+ - Other parameters must be one of:
145
+ - `Depends(...)` default or `Annotated[T, Depends(...)]`
146
+ - `Annotated[T, Query()/Header()/Cookie()/Path()/Form()/File()]` or the equivalent `= Query(...)` default
147
+ - `*args` / `**kwargs` are rejected at decoration time.
148
+ - Function body must `return` `None` or a `str` (the dynamic prompt). Anything else raises at request time.
149
+ - `async def` is supported.
150
+
151
+ Decoration kwargs:
152
+
153
+ - Passed through to FastAPI: `tags`, `summary`, `description`, `status_code`, `deprecated`, `operation_id`, `name`, `include_in_schema`, `responses`, `openapi_extra`.
154
+ - Rejected at decoration time with `YapiDeclarationError`: `response_model`, `response_class`, `dependencies`.
155
+ - Any other unknown kwarg emits a `YapiUsageWarning`.
156
+
157
+ Violations are raised as `YapiDeclarationError` at decoration time — broken routes fail at import, not at request time.
158
+
159
+ ## Prompt context
160
+
161
+ Use `PromptContext` to inject structured facts into the system prompt without returning a string. Declare a parameter typed `PromptContext` and `yapi` auto-injects a per-request instance:
162
+
163
+ ```python
164
+ from yapi import PromptContext, PromptRouter
165
+
166
+ router = PromptRouter()
167
+
168
+
169
+ @router.prompt.post("/wish")
170
+ def make_a_wish(req: WishIn, ctx: PromptContext) -> WishOut:
171
+ """Decide whether to grant the user's wish."""
172
+ ctx.add_section("User Profile", {"vip": req.user_id.startswith("vip-")})
173
+ ctx.add_kv("user_id", req.user_id)
174
+ ctx.add(req.wish)
175
+ ```
176
+
177
+ `yapi` collects all segments and wraps them in `<context>…</context>` at the end of the system prompt:
178
+
179
+ ```
180
+ You are the execution engine…
181
+
182
+ Decide whether to grant the user's wish.
183
+
184
+ <context>
185
+ # User Profile
186
+ {"vip": true}
187
+
188
+ user_id: vip-1
189
+
190
+ moon
191
+ </context>
192
+ ```
193
+
194
+ Three methods:
195
+
196
+ | Method | Produces |
197
+ |---|---|
198
+ | `ctx.add(value)` | `<serialized value>` |
199
+ | `ctx.add_kv(key, value)` | `{key}: <serialized value>` |
200
+ | `ctx.add_section(name, body)` | `# {name}\n<serialized body>` |
201
+
202
+ Value serialization: `str` → pass-through; `BaseModel` → `model_dump_json()`; `dict`/`list`/`tuple` → `json.dumps(..., ensure_ascii=False)`; anything else → `str()`. `None` is rejected — use `""` if you want an empty segment.
203
+
204
+ `PromptContext` is **append-only** — no `clear` / `pop`. Use Python `if` for conditional adds. At most one `PromptContext` parameter per route; the parameter must not carry FastAPI markers (`Annotated[PromptContext, Body()/Query()/Depends()]` is a declaration error).
205
+
206
+ State retrieval is out of scope for `yapi`. Fetch your data via `Depends(...)` and pass it to `ctx.*`. See `examples/state_via_depends.py`.
207
+
208
+ ## Dependency injection
209
+
210
+ ```python
211
+ from fastapi import Depends
212
+ from typing import Annotated
213
+
214
+ def get_db():
215
+ ...
216
+
217
+ @router.prompt.post("/wish")
218
+ def make_a_wish(
219
+ req: WishIn,
220
+ db: Annotated[Database, Depends(get_db)],
221
+ ) -> WishOut:
222
+ """..."""
223
+ return f"user has {db.balance(req.user_id)} wishes left"
224
+ ```
225
+
226
+ ## Custom agent runner
227
+
228
+ Implement the `AgentRunner` Protocol — any object with a `.run(ctx: RunnerContext) -> dict | BaseModel` method is accepted:
229
+
230
+ ```python
231
+ from yapi import AgentRunner, PromptRouter, RunnerContext
232
+
233
+ class MockRunner:
234
+ def run(self, ctx: RunnerContext) -> dict:
235
+ return {
236
+ "granted": "moon" not in ctx.request["wish"].lower(),
237
+ "message": f"path={ctx.path}",
238
+ }
239
+
240
+ router = PromptRouter(agent_runner=MockRunner())
241
+ ```
242
+
243
+ The legacy v2-style `(*, prompt, request, injected, response_model) -> dict` callable is still accepted (auto-adapted).
244
+
245
+ You can also inject a custom `prompt_composer=` to customize how the system prompt is assembled.
246
+
247
+ ## Development
248
+
249
+ ```bash
250
+ uv sync --extra dev
251
+ uv run pytest
252
+ uv run uvicorn examples.wish_api:app --reload
253
+ ```
254
+
255
+ ## License
256
+
257
+ MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyyapi"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "Prompt-first declarative HTTP framework on top of FastAPI and PydanticAI"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -50,6 +50,9 @@ Issues = "https://github.com/TokenRollAI/yapi/issues"
50
50
  include = ["yapi*"]
51
51
  exclude = ["tests*", "examples*", "docs*", "llmdoc*"]
52
52
 
53
+ [tool.setuptools.package-data]
54
+ yapi = ["py.typed"]
55
+
53
56
  [tool.pytest.ini_options]
54
57
  pythonpath = ["."]
55
58
  testpaths = ["tests"]