pyyapi 0.2.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 (28) hide show
  1. {pyyapi-0.2.0/pyyapi.egg-info → pyyapi-0.3.0}/PKG-INFO +53 -2
  2. {pyyapi-0.2.0 → pyyapi-0.3.0}/README.md +52 -1
  3. {pyyapi-0.2.0 → pyyapi-0.3.0}/pyproject.toml +1 -1
  4. {pyyapi-0.2.0 → pyyapi-0.3.0/pyyapi.egg-info}/PKG-INFO +53 -2
  5. {pyyapi-0.2.0 → pyyapi-0.3.0}/pyyapi.egg-info/SOURCES.txt +2 -0
  6. {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_integration.py +82 -0
  7. pyyapi-0.3.0/tests/test_prompt_context.py +89 -0
  8. {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_router.py +116 -0
  9. {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_runtime.py +57 -0
  10. {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/__init__.py +2 -0
  11. pyyapi-0.3.0/yapi/prompt_context.py +39 -0
  12. {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/router.py +62 -9
  13. {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/runtime.py +38 -8
  14. {pyyapi-0.2.0 → pyyapi-0.3.0}/LICENSE +0 -0
  15. {pyyapi-0.2.0 → pyyapi-0.3.0}/pyyapi.egg-info/dependency_links.txt +0 -0
  16. {pyyapi-0.2.0 → pyyapi-0.3.0}/pyyapi.egg-info/requires.txt +0 -0
  17. {pyyapi-0.2.0 → pyyapi-0.3.0}/pyyapi.egg-info/top_level.txt +0 -0
  18. {pyyapi-0.2.0 → pyyapi-0.3.0}/setup.cfg +0 -0
  19. {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_compat.py +0 -0
  20. {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_dx.py +0 -0
  21. {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_exports.py +0 -0
  22. {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_runner.py +0 -0
  23. {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/agent.py +0 -0
  24. {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/endpoint.py +0 -0
  25. {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/errors.py +0 -0
  26. {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/models.py +0 -0
  27. {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/py.typed +0 -0
  28. {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyyapi
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Prompt-first declarative HTTP framework on top of FastAPI and PydanticAI
5
5
  Author-email: DJJ <shuaiqijianhao@qq.com>
6
6
  License: MIT
@@ -133,6 +133,8 @@ YAPI_MODEL=test # PydanticAI TestModel, no key, no networ
133
133
 
134
134
  Unset → constructor emits a `YapiUsageWarning`, first request returns HTTP 500.
135
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
+
136
138
  ### Provider credentials (read directly by PydanticAI)
137
139
 
138
140
  `yapi` does **not** validate or even look at these — they are consumed by the underlying PydanticAI provider via `os.environ`:
@@ -156,7 +158,7 @@ OPENAI_BASE_URL=https://api.deepseek.com/v1
156
158
  uv run uvicorn examples.wish_api:app --reload --env-file .env
157
159
  ```
158
160
 
159
- > DeepSeek's "thinking" models (`deepseek-reasoner`, `deepseek-v4-flash`) currently reject OpenAI Function Calling's `tool_choice` parameter, which PydanticAI uses by default for structured output. Use `deepseek-chat` for now.
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`.
160
162
 
161
163
  ## How a prompt route runs
162
164
 
@@ -189,6 +191,55 @@ Decoration kwargs:
189
191
 
190
192
  Violations are raised as `YapiDeclarationError` at decoration time — broken routes fail at import, not at request time.
191
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
+
192
243
  ## Dependency injection
193
244
 
194
245
  ```python
@@ -98,6 +98,8 @@ YAPI_MODEL=test # PydanticAI TestModel, no key, no networ
98
98
 
99
99
  Unset → constructor emits a `YapiUsageWarning`, first request returns HTTP 500.
100
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
+
101
103
  ### Provider credentials (read directly by PydanticAI)
102
104
 
103
105
  `yapi` does **not** validate or even look at these — they are consumed by the underlying PydanticAI provider via `os.environ`:
@@ -121,7 +123,7 @@ OPENAI_BASE_URL=https://api.deepseek.com/v1
121
123
  uv run uvicorn examples.wish_api:app --reload --env-file .env
122
124
  ```
123
125
 
124
- > DeepSeek's "thinking" models (`deepseek-reasoner`, `deepseek-v4-flash`) currently reject OpenAI Function Calling's `tool_choice` parameter, which PydanticAI uses by default for structured output. Use `deepseek-chat` for now.
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`.
125
127
 
126
128
  ## How a prompt route runs
127
129
 
@@ -154,6 +156,55 @@ Decoration kwargs:
154
156
 
155
157
  Violations are raised as `YapiDeclarationError` at decoration time — broken routes fail at import, not at request time.
156
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
+
157
208
  ## Dependency injection
158
209
 
159
210
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyyapi"
7
- version = "0.2.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" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyyapi
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Prompt-first declarative HTTP framework on top of FastAPI and PydanticAI
5
5
  Author-email: DJJ <shuaiqijianhao@qq.com>
6
6
  License: MIT
@@ -133,6 +133,8 @@ YAPI_MODEL=test # PydanticAI TestModel, no key, no networ
133
133
 
134
134
  Unset → constructor emits a `YapiUsageWarning`, first request returns HTTP 500.
135
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
+
136
138
  ### Provider credentials (read directly by PydanticAI)
137
139
 
138
140
  `yapi` does **not** validate or even look at these — they are consumed by the underlying PydanticAI provider via `os.environ`:
@@ -156,7 +158,7 @@ OPENAI_BASE_URL=https://api.deepseek.com/v1
156
158
  uv run uvicorn examples.wish_api:app --reload --env-file .env
157
159
  ```
158
160
 
159
- > DeepSeek's "thinking" models (`deepseek-reasoner`, `deepseek-v4-flash`) currently reject OpenAI Function Calling's `tool_choice` parameter, which PydanticAI uses by default for structured output. Use `deepseek-chat` for now.
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`.
160
162
 
161
163
  ## How a prompt route runs
162
164
 
@@ -189,6 +191,55 @@ Decoration kwargs:
189
191
 
190
192
  Violations are raised as `YapiDeclarationError` at decoration time — broken routes fail at import, not at request time.
191
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
+
192
243
  ## Dependency injection
193
244
 
194
245
  ```python
@@ -10,6 +10,7 @@ tests/test_compat.py
10
10
  tests/test_dx.py
11
11
  tests/test_exports.py
12
12
  tests/test_integration.py
13
+ tests/test_prompt_context.py
13
14
  tests/test_router.py
14
15
  tests/test_runner.py
15
16
  tests/test_runtime.py
@@ -18,6 +19,7 @@ yapi/agent.py
18
19
  yapi/endpoint.py
19
20
  yapi/errors.py
20
21
  yapi/models.py
22
+ yapi/prompt_context.py
21
23
  yapi/py.typed
22
24
  yapi/router.py
23
25
  yapi/runner.py
@@ -87,3 +87,85 @@ def test_handler_returning_non_string_raises_runtime_error() -> None:
87
87
  response = client.post("/wish", json={"user_id": "u-1", "wish": "moon"})
88
88
 
89
89
  assert response.status_code == 500
90
+
91
+
92
+ # ---- v2.2 PromptContext e2e ----
93
+
94
+
95
+ from yapi import PromptContext
96
+
97
+
98
+ def test_e2e_ctx_segments_reach_runner() -> None:
99
+ captured = {}
100
+
101
+ def fake_runner(**kwargs):
102
+ captured.update(kwargs)
103
+ return {"granted": True, "message": "ok"}
104
+
105
+ app = FastAPI()
106
+ router = PromptRouter(agent_runner=fake_runner)
107
+
108
+ @router.prompt.post("/wish")
109
+ def make_a_wish(req: WishRequest, ctx: PromptContext) -> WishResponse:
110
+ """grant wishes"""
111
+ ctx.add_section("User", req.user_id)
112
+ ctx.add_kv("wish", req.wish)
113
+ ctx.add("extra hint")
114
+
115
+ app.include_router(router)
116
+ client = TestClient(app)
117
+ resp = client.post("/wish", json={"user_id": "u-1", "wish": "moon"})
118
+
119
+ assert resp.status_code == 200
120
+ prompt = captured["prompt"]
121
+ assert "<context>" in prompt
122
+ assert "# User" in prompt
123
+ assert "u-1" in prompt
124
+ assert "wish: moon" in prompt
125
+ assert "extra hint" in prompt
126
+
127
+
128
+ def test_e2e_v21_route_with_return_str_wraps_in_context() -> None:
129
+ captured = {}
130
+
131
+ def fake_runner(**kwargs):
132
+ captured.update(kwargs)
133
+ return {"granted": True, "message": "ok"}
134
+
135
+ app = FastAPI()
136
+ router = PromptRouter(agent_runner=fake_runner)
137
+
138
+ @router.prompt.post("/wish")
139
+ def make_a_wish(req: WishRequest) -> WishResponse:
140
+ """grant wishes"""
141
+ return "legacy hint string"
142
+
143
+ app.include_router(router)
144
+ client = TestClient(app)
145
+ resp = client.post("/wish", json={"user_id": "u-1", "wish": "moon"})
146
+
147
+ assert resp.status_code == 200
148
+ prompt = captured["prompt"]
149
+ assert "<context>\nlegacy hint string\n</context>" in prompt
150
+
151
+
152
+ def test_e2e_route_with_no_ctx_and_no_return_skips_context_tag() -> None:
153
+ captured = {}
154
+
155
+ def fake_runner(**kwargs):
156
+ captured.update(kwargs)
157
+ return {"granted": True, "message": "ok"}
158
+
159
+ app = FastAPI()
160
+ router = PromptRouter(agent_runner=fake_runner)
161
+
162
+ @router.prompt.post("/wish")
163
+ def make_a_wish(req: WishRequest) -> WishResponse:
164
+ """grant wishes"""
165
+
166
+ app.include_router(router)
167
+ client = TestClient(app)
168
+ resp = client.post("/wish", json={"user_id": "u-1", "wish": "moon"})
169
+
170
+ assert resp.status_code == 200
171
+ assert "<context>" not in captured["prompt"]
@@ -0,0 +1,89 @@
1
+ import pytest
2
+ from pydantic import BaseModel
3
+
4
+ from yapi.errors import RuntimeExecutionError
5
+ from yapi.prompt_context import PromptContext, _format_value
6
+
7
+
8
+ class SomeModel(BaseModel):
9
+ name: str
10
+ vip: bool
11
+
12
+
13
+ def test_add_str_passes_through():
14
+ ctx = PromptContext()
15
+ ctx.add("hello")
16
+ assert ctx.segments() == ("hello",)
17
+
18
+
19
+ def test_add_basemodel_uses_model_dump_json():
20
+ ctx = PromptContext()
21
+ model = SomeModel(name="djj", vip=True)
22
+ ctx.add(model)
23
+ import json
24
+ parsed = json.loads(ctx.segments()[0])
25
+ assert parsed == {"name": "djj", "vip": True}
26
+
27
+
28
+ def test_add_dict_uses_json_dumps_with_ensure_ascii_false():
29
+ ctx = PromptContext()
30
+ ctx.add({"k": "中"})
31
+ assert ctx.segments()[0] == '{"k": "中"}'
32
+
33
+
34
+ def test_add_list_uses_json_dumps():
35
+ ctx = PromptContext()
36
+ ctx.add([1, "中", True])
37
+ assert ctx.segments()[0] == '[1, "中", true]'
38
+
39
+
40
+ def test_add_tuple_uses_json_dumps():
41
+ ctx = PromptContext()
42
+ ctx.add((1, 2))
43
+ assert ctx.segments()[0] == "[1, 2]"
44
+
45
+
46
+ def test_add_other_uses_str():
47
+ ctx = PromptContext()
48
+ ctx.add(42)
49
+ assert ctx.segments()[0] == "42"
50
+
51
+
52
+ def test_add_kv_format():
53
+ ctx = PromptContext()
54
+ ctx.add_kv("k", {"x": 1})
55
+ assert ctx.segments()[0] == 'k: {"x": 1}'
56
+
57
+
58
+ def test_add_section_format():
59
+ ctx = PromptContext()
60
+ model = SomeModel(name="djj", vip=True)
61
+ ctx.add_section("Profile", model)
62
+ seg = ctx.segments()[0]
63
+ assert seg.startswith("# Profile\n")
64
+ import json
65
+ body = seg[len("# Profile\n"):]
66
+ parsed = json.loads(body)
67
+ assert parsed == {"name": "djj", "vip": True}
68
+
69
+
70
+ def test_add_none_raises_runtime_error():
71
+ ctx = PromptContext()
72
+ with pytest.raises(RuntimeExecutionError):
73
+ ctx.add(None)
74
+
75
+
76
+ def test_segments_returns_in_call_order():
77
+ ctx = PromptContext()
78
+ ctx.add("first")
79
+ ctx.add_kv("key", "second")
80
+ ctx.add_section("sec", "third")
81
+ segs = ctx.segments()
82
+ assert segs[0] == "first"
83
+ assert segs[1] == "key: second"
84
+ assert segs[2] == "# sec\nthird"
85
+
86
+
87
+ def test_empty_segments_returns_empty_tuple():
88
+ ctx = PromptContext()
89
+ assert ctx.segments() == ()
@@ -430,3 +430,119 @@ def test_prompt_handler_signature_preserves_annotated() -> None:
430
430
  q_param = sig.parameters["q"]
431
431
  assert hasattr(req_param.annotation, "__metadata__")
432
432
  assert hasattr(q_param.annotation, "__metadata__")
433
+
434
+
435
+ # ---- v2.2 PromptContext ----
436
+
437
+
438
+ from yapi import PromptContext
439
+
440
+
441
+ def test_router_injects_prompt_context_by_type() -> None:
442
+ captured = {}
443
+ app = FastAPI()
444
+
445
+ def runner(**kwargs):
446
+ captured.update(kwargs)
447
+ return {"granted": True, "message": "ok"}
448
+
449
+ router = PromptRouter(agent_runner=runner)
450
+
451
+ @router.prompt.post("/wish")
452
+ def make_a_wish(req: WishRequest, ctx: PromptContext) -> WishResponse:
453
+ """grant wishes"""
454
+ ctx.add_section("User", req.user_id)
455
+
456
+ app.include_router(router)
457
+ client = TestClient(app)
458
+ resp = client.post("/wish", json={"user_id": "u-1", "wish": "moon"})
459
+ assert resp.status_code == 200
460
+ assert "<context>" in captured["prompt"]
461
+ assert "# User" in captured["prompt"]
462
+ assert "u-1" in captured["prompt"]
463
+
464
+
465
+ def test_router_injects_prompt_context_async() -> None:
466
+ captured = {}
467
+ app = FastAPI()
468
+
469
+ def runner(**kwargs):
470
+ captured.update(kwargs)
471
+ return {"granted": True, "message": "ok"}
472
+
473
+ router = PromptRouter(agent_runner=runner)
474
+
475
+ @router.prompt.post("/wish")
476
+ async def make_a_wish(req: WishRequest, ctx: PromptContext) -> WishResponse:
477
+ """grant wishes"""
478
+ ctx.add_kv("wish", req.wish)
479
+
480
+ app.include_router(router)
481
+ client = TestClient(app)
482
+ resp = client.post("/wish", json={"user_id": "u-1", "wish": "stars"})
483
+ assert resp.status_code == 200
484
+ assert "wish: stars" in captured["prompt"]
485
+
486
+
487
+ def test_router_prompt_context_param_name_is_arbitrary() -> None:
488
+ captured = {}
489
+ app = FastAPI()
490
+
491
+ def runner(**kwargs):
492
+ captured.update(kwargs)
493
+ return {"granted": True, "message": "ok"}
494
+
495
+ router = PromptRouter(agent_runner=runner)
496
+
497
+ @router.prompt.post("/wish")
498
+ def make_a_wish(req: WishRequest, prompt_ctx: PromptContext) -> WishResponse:
499
+ """grant wishes"""
500
+ prompt_ctx.add("from prompt_ctx")
501
+
502
+ app.include_router(router)
503
+ client = TestClient(app)
504
+ resp = client.post("/wish", json={"user_id": "u-1", "wish": "moon"})
505
+ assert resp.status_code == 200
506
+ assert "from prompt_ctx" in captured["prompt"]
507
+
508
+
509
+ def test_router_rejects_two_prompt_context_params() -> None:
510
+ router = PromptRouter(agent_runner=_ok_runner)
511
+
512
+ with pytest.raises(YapiDeclarationError, match="at most one PromptContext"):
513
+
514
+ @router.prompt.post("/wish")
515
+ def make_a_wish(
516
+ req: WishRequest,
517
+ ctx1: PromptContext,
518
+ ctx2: PromptContext,
519
+ ) -> WishResponse:
520
+ """grant wishes"""
521
+
522
+
523
+ def test_router_rejects_prompt_context_with_fastapi_marker() -> None:
524
+ router = PromptRouter(agent_runner=_ok_runner)
525
+
526
+ with pytest.raises(YapiDeclarationError, match="must not carry FastAPI markers"):
527
+
528
+ @router.prompt.post("/wish")
529
+ def make_a_wish(
530
+ req: WishRequest,
531
+ ctx: Annotated[PromptContext, Body()],
532
+ ) -> WishResponse:
533
+ """grant wishes"""
534
+
535
+
536
+ def test_router_prompt_context_not_in_openapi() -> None:
537
+ app = FastAPI()
538
+ router = PromptRouter(agent_runner=_ok_runner)
539
+
540
+ @router.prompt.post("/wish")
541
+ def make_a_wish(req: WishRequest, ctx: PromptContext) -> WishResponse:
542
+ """grant wishes"""
543
+ ctx.add("hint")
544
+
545
+ app.include_router(router)
546
+ schema = app.openapi()
547
+ schema_str = str(schema)
548
+ assert "PromptContext" not in schema_str
@@ -95,3 +95,60 @@ def test_runtime_sends_composed_prompt_to_agent_runner() -> None:
95
95
  assert "user is shouting" in captured["prompt"]
96
96
  assert captured["request"] == {"user_id": "u-1", "wish": "moon"}
97
97
  assert captured["injected"] == {"profile": {"vip": True}}
98
+
99
+
100
+ from yapi.prompt_context import PromptContext
101
+ from yapi.runtime import compose_prompt
102
+
103
+
104
+ def test_compose_skips_context_when_empty():
105
+ endpoint = PromptEndpoint(
106
+ path="/wish",
107
+ method="POST",
108
+ request_model=None,
109
+ response_model=WishResponse,
110
+ function_doc="grant wishes",
111
+ )
112
+ prompt = compose_prompt(endpoint, None, None)
113
+ assert "<context>" not in prompt
114
+
115
+
116
+ def test_compose_wraps_single_ctx_segment():
117
+ endpoint = PromptEndpoint(
118
+ path="/wish",
119
+ method="POST",
120
+ request_model=None,
121
+ response_model=WishResponse,
122
+ function_doc="grant wishes",
123
+ )
124
+ ctx = PromptContext()
125
+ ctx.add("hint segment")
126
+ prompt = compose_prompt(endpoint, ctx, None)
127
+ assert "<context>\nhint segment\n</context>" in prompt
128
+
129
+
130
+ def test_compose_wraps_dynamic_only():
131
+ endpoint = PromptEndpoint(
132
+ path="/wish",
133
+ method="POST",
134
+ request_model=None,
135
+ response_model=WishResponse,
136
+ function_doc="grant wishes",
137
+ )
138
+ prompt = compose_prompt(endpoint, None, "dynamic text")
139
+ assert "<context>\ndynamic text\n</context>" in prompt
140
+
141
+
142
+ def test_compose_concatenates_ctx_then_dynamic():
143
+ endpoint = PromptEndpoint(
144
+ path="/wish",
145
+ method="POST",
146
+ request_model=None,
147
+ response_model=WishResponse,
148
+ function_doc="grant wishes",
149
+ )
150
+ ctx = PromptContext()
151
+ ctx.add("s1")
152
+ ctx.add("s2")
153
+ prompt = compose_prompt(endpoint, ctx, "dynamic")
154
+ assert "<context>\ns1\n\ns2\n\ndynamic\n</context>" in prompt
@@ -4,11 +4,13 @@ from yapi.errors import (
4
4
  YapiError,
5
5
  YapiUsageWarning,
6
6
  )
7
+ from yapi.prompt_context import PromptContext
7
8
  from yapi.router import PromptRouter
8
9
  from yapi.runner import AgentRunner, RunnerContext
9
10
 
10
11
  __all__ = [
11
12
  "PromptRouter",
13
+ "PromptContext",
12
14
  "AgentRunner",
13
15
  "RunnerContext",
14
16
  "YapiError",
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from yapi.errors import RuntimeExecutionError
9
+
10
+
11
+ def _format_value(value: Any) -> str:
12
+ if value is None:
13
+ raise RuntimeExecutionError(
14
+ "PromptContext does not accept None; use an empty string if you want an empty segment."
15
+ )
16
+ if isinstance(value, str):
17
+ return value
18
+ if isinstance(value, BaseModel):
19
+ return value.model_dump_json()
20
+ if isinstance(value, (dict, list, tuple)):
21
+ return json.dumps(value, ensure_ascii=False)
22
+ return str(value)
23
+
24
+
25
+ class PromptContext:
26
+ def __init__(self) -> None:
27
+ self._segments: list[str] = []
28
+
29
+ def add(self, value: Any) -> None:
30
+ self._segments.append(_format_value(value))
31
+
32
+ def add_kv(self, key: str, value: Any) -> None:
33
+ self._segments.append(f"{key}: {_format_value(value)}")
34
+
35
+ def add_section(self, name: str, body: Any) -> None:
36
+ self._segments.append(f"# {name}\n{_format_value(body)}")
37
+
38
+ def segments(self) -> tuple[str, ...]:
39
+ return tuple(self._segments)
@@ -14,6 +14,7 @@ from pydantic import BaseModel
14
14
  from yapi.agent import build_default_runner
15
15
  from yapi.endpoint import PromptEndpoint
16
16
  from yapi.errors import RuntimeExecutionError, YapiDeclarationError, YapiUsageWarning
17
+ from yapi.prompt_context import PromptContext
17
18
  from yapi.runner import AgentRunner
18
19
  from yapi.runtime import PromptComposer, Runtime
19
20
 
@@ -63,6 +64,7 @@ class ParamRole(Enum):
63
64
  REQUEST_MODEL = "request_model"
64
65
  DEPENDENCY = "dependency"
65
66
  INJECTED_FIELD = "injected_field"
67
+ PROMPT_CONTEXT = "prompt_context"
66
68
 
67
69
 
68
70
  def _unwrap_annotated(annotation: Any) -> tuple[Any, tuple[Any, ...]]:
@@ -75,6 +77,10 @@ def _is_basemodel_type(tp: Any) -> bool:
75
77
  return isinstance(tp, type) and issubclass(tp, BaseModel)
76
78
 
77
79
 
80
+ def _is_prompt_context_type(tp: Any) -> bool:
81
+ return isinstance(tp, type) and issubclass(tp, PromptContext)
82
+
83
+
78
84
  def _classify_param(
79
85
  name: str,
80
86
  param: inspect.Parameter,
@@ -90,9 +96,22 @@ def _classify_param(
90
96
  )
91
97
 
92
98
  annotation = param.annotation
93
- default = param.default
99
+
100
+ # PromptContext bare type → auto-inject role (must be checked before _unwrap_annotated)
101
+ if _is_prompt_context_type(annotation):
102
+ return ParamRole.PROMPT_CONTEXT
103
+
94
104
  base_annotation, metadata = _unwrap_annotated(annotation)
95
105
 
106
+ # PromptContext inside Annotated[...] with markers is forbidden
107
+ if _is_prompt_context_type(base_annotation) and metadata:
108
+ raise YapiDeclarationError(
109
+ f"yapi prompt route '{func_name}' parameter '{name}': "
110
+ "PromptContext is auto-injected by yapi and must not carry FastAPI markers"
111
+ )
112
+
113
+ default = param.default
114
+
96
115
  if isinstance(default, params.Depends):
97
116
  return ParamRole.DEPENDENCY
98
117
 
@@ -134,7 +153,13 @@ def _classify_param(
134
153
 
135
154
  def _introspect(
136
155
  func: Callable,
137
- ) -> tuple[type[BaseModel] | None, type[BaseModel], dict[str, ParamRole], str | None]:
156
+ ) -> tuple[
157
+ type[BaseModel] | None,
158
+ type[BaseModel],
159
+ dict[str, ParamRole],
160
+ str | None,
161
+ str | None,
162
+ ]:
138
163
  signature = inspect.signature(func)
139
164
 
140
165
  return_annotation = signature.return_annotation
@@ -156,6 +181,7 @@ def _introspect(
156
181
  param_roles: dict[str, ParamRole] = {}
157
182
  request_model: type[BaseModel] | None = None
158
183
  request_param_name: str | None = None
184
+ ctx_param_name: str | None = None
159
185
 
160
186
  for name, param in signature.parameters.items():
161
187
  role = _classify_param(name, param, func.__name__)
@@ -169,8 +195,14 @@ def _introspect(
169
195
  base_annotation, _ = _unwrap_annotated(param.annotation)
170
196
  request_model = base_annotation
171
197
  request_param_name = name
198
+ elif role is ParamRole.PROMPT_CONTEXT:
199
+ if ctx_param_name is not None:
200
+ raise YapiDeclarationError(
201
+ f"yapi prompt route '{func.__name__}' may declare at most one PromptContext parameter"
202
+ )
203
+ ctx_param_name = name
172
204
 
173
- return request_model, return_annotation, param_roles, request_param_name
205
+ return request_model, return_annotation, param_roles, request_param_name, ctx_param_name
174
206
 
175
207
 
176
208
  def _validate_prompt_kwargs(path: str, kwargs: dict[str, Any]) -> dict[str, Any]:
@@ -238,7 +270,13 @@ class PromptRouter(APIRouter):
238
270
  passthrough = _validate_prompt_kwargs(path, kwargs)
239
271
 
240
272
  def decorator(func: Callable) -> Callable:
241
- request_model, response_model, param_roles, request_param_name = _introspect(func)
273
+ (
274
+ request_model,
275
+ response_model,
276
+ param_roles,
277
+ request_param_name,
278
+ ctx_param_name,
279
+ ) = _introspect(func)
242
280
 
243
281
  endpoint = PromptEndpoint(
244
282
  path=path,
@@ -255,7 +293,11 @@ class PromptRouter(APIRouter):
255
293
  async def handler(**kwargs):
256
294
  injected: dict[str, Any] = {}
257
295
  request_instance: BaseModel | None = None
296
+ ctx = PromptContext() if ctx_param_name else None
297
+
258
298
  for name, role in param_roles.items():
299
+ if name == ctx_param_name:
300
+ continue
259
301
  if name not in kwargs:
260
302
  continue
261
303
  if role is ParamRole.REQUEST_MODEL:
@@ -263,10 +305,14 @@ class PromptRouter(APIRouter):
263
305
  else:
264
306
  injected[name] = kwargs[name]
265
307
 
308
+ user_kwargs = dict(kwargs)
309
+ if ctx_param_name is not None:
310
+ user_kwargs[ctx_param_name] = ctx
311
+
266
312
  if is_async:
267
- dynamic_prompt = await func(**kwargs)
313
+ dynamic_prompt = await func(**user_kwargs)
268
314
  else:
269
- dynamic_prompt = func(**kwargs)
315
+ dynamic_prompt = func(**user_kwargs)
270
316
 
271
317
  if dynamic_prompt is not None and not isinstance(dynamic_prompt, str):
272
318
  raise RuntimeExecutionError(
@@ -279,16 +325,22 @@ class PromptRouter(APIRouter):
279
325
  endpoint=endpoint,
280
326
  request_model=request_instance,
281
327
  injected=injected,
328
+ prompt_context=ctx,
282
329
  dynamic_prompt=dynamic_prompt,
283
330
  )
284
331
 
332
+ # FastAPI-visible params: filter out PROMPT_CONTEXT
333
+ fastapi_visible_params = [
334
+ p for p in original_params
335
+ if param_roles.get(p.name) is not ParamRole.PROMPT_CONTEXT
336
+ ]
285
337
  handler.__signature__ = inspect.Signature(
286
- parameters=original_params,
338
+ parameters=fastapi_visible_params,
287
339
  return_annotation=response_model,
288
340
  )
289
341
  handler.__annotations__ = {
290
342
  p.name: p.annotation
291
- for p in original_params
343
+ for p in fastapi_visible_params
292
344
  if p.annotation is not inspect.Parameter.empty
293
345
  }
294
346
  handler.__annotations__["return"] = response_model
@@ -296,11 +348,12 @@ class PromptRouter(APIRouter):
296
348
  handler.__doc__ = func.__doc__
297
349
 
298
350
  logger.debug(
299
- "registering prompt route method=%s path=%s handler=%s async=%s",
351
+ "registering prompt route method=%s path=%s handler=%s async=%s ctx=%s",
300
352
  upper,
301
353
  path,
302
354
  func.__name__,
303
355
  is_async,
356
+ ctx_param_name,
304
357
  )
305
358
 
306
359
  self.add_api_route(
@@ -2,12 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  from collections.abc import Callable
5
+ from typing import Any
5
6
 
6
7
  from pydantic import BaseModel
7
8
 
8
9
  from yapi.endpoint import PromptEndpoint
9
10
  from yapi.errors import RuntimeExecutionError
10
11
  from yapi.models import RuntimeContext
12
+ from yapi.prompt_context import PromptContext
11
13
  from yapi.runner import AgentRunner, RunnerContext, _coerce_runner
12
14
 
13
15
  logger = logging.getLogger("yapi.runtime")
@@ -18,21 +20,46 @@ DEFAULT_SYSTEM_PREFIX = (
18
20
  )
19
21
 
20
22
 
21
- PromptComposer = Callable[[PromptEndpoint, str | None], str]
23
+ PromptComposer = Callable[[PromptEndpoint, "PromptContext | None", "str | None"], str]
22
24
 
23
25
 
24
- def compose_prompt(endpoint: PromptEndpoint, dynamic_prompt: str | None) -> str:
26
+ def compose_prompt(
27
+ endpoint: PromptEndpoint,
28
+ prompt_context: PromptContext | None,
29
+ dynamic_prompt: str | None,
30
+ ) -> str:
25
31
  sections = [DEFAULT_SYSTEM_PREFIX]
26
- response_doc = endpoint.response_doc
27
- if response_doc:
28
- sections.append(response_doc)
32
+ if endpoint.response_doc:
33
+ sections.append(endpoint.response_doc)
29
34
  if endpoint.function_doc:
30
35
  sections.append(endpoint.function_doc)
36
+
37
+ ctx_segments = list(prompt_context.segments()) if prompt_context else []
31
38
  if dynamic_prompt:
32
- sections.append(dynamic_prompt)
39
+ ctx_segments.append(dynamic_prompt)
40
+ if ctx_segments:
41
+ body = "\n\n".join(ctx_segments)
42
+ sections.append(f"<context>\n{body}\n</context>")
43
+
33
44
  return "\n\n".join(sections)
34
45
 
35
46
 
47
+ def _adapt_composer(composer: Any) -> PromptComposer:
48
+ """Wrap a possibly v2.1-style (endpoint, dynamic_prompt) composer for v2.2 calls."""
49
+
50
+ def adapted(
51
+ endpoint: PromptEndpoint,
52
+ prompt_context: PromptContext | None,
53
+ dynamic_prompt: str | None,
54
+ ) -> str:
55
+ try:
56
+ return composer(endpoint, prompt_context, dynamic_prompt)
57
+ except TypeError:
58
+ return composer(endpoint, dynamic_prompt)
59
+
60
+ return adapted
61
+
62
+
36
63
  class Runtime:
37
64
  def __init__(
38
65
  self,
@@ -40,7 +67,9 @@ class Runtime:
40
67
  prompt_composer: PromptComposer | None = None,
41
68
  ) -> None:
42
69
  self._agent_runner: AgentRunner = _coerce_runner(agent_runner)
43
- self._compose_prompt: PromptComposer = prompt_composer or compose_prompt
70
+ self._compose_prompt: PromptComposer = (
71
+ _adapt_composer(prompt_composer) if prompt_composer is not None else compose_prompt
72
+ )
44
73
 
45
74
  def build_context(self, request_data: dict, injected: dict) -> RuntimeContext:
46
75
  return RuntimeContext(
@@ -54,10 +83,11 @@ class Runtime:
54
83
  request_model: BaseModel | None,
55
84
  injected: dict,
56
85
  dynamic_prompt: str | None,
86
+ prompt_context: PromptContext | None = None,
57
87
  ) -> BaseModel:
58
88
  request_data = {} if request_model is None else request_model.model_dump()
59
89
  context = self.build_context(request_data=request_data, injected=injected)
60
- prompt = self._compose_prompt(endpoint, dynamic_prompt)
90
+ prompt = self._compose_prompt(endpoint, prompt_context, dynamic_prompt)
61
91
 
62
92
  logger.debug(
63
93
  "execute path=%s method=%s has_request_model=%s injected_keys=%s",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes