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.
- {pyyapi-0.2.0/pyyapi.egg-info → pyyapi-0.3.0}/PKG-INFO +53 -2
- {pyyapi-0.2.0 → pyyapi-0.3.0}/README.md +52 -1
- {pyyapi-0.2.0 → pyyapi-0.3.0}/pyproject.toml +1 -1
- {pyyapi-0.2.0 → pyyapi-0.3.0/pyyapi.egg-info}/PKG-INFO +53 -2
- {pyyapi-0.2.0 → pyyapi-0.3.0}/pyyapi.egg-info/SOURCES.txt +2 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_integration.py +82 -0
- pyyapi-0.3.0/tests/test_prompt_context.py +89 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_router.py +116 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_runtime.py +57 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/__init__.py +2 -0
- pyyapi-0.3.0/yapi/prompt_context.py +39 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/router.py +62 -9
- {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/runtime.py +38 -8
- {pyyapi-0.2.0 → pyyapi-0.3.0}/LICENSE +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/pyyapi.egg-info/dependency_links.txt +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/pyyapi.egg-info/requires.txt +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/pyyapi.egg-info/top_level.txt +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/setup.cfg +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_compat.py +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_dx.py +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_exports.py +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/tests/test_runner.py +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/agent.py +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/endpoint.py +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/errors.py +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/models.py +0 -0
- {pyyapi-0.2.0 → pyyapi-0.3.0}/yapi/py.typed +0 -0
- {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.
|
|
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`)
|
|
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`)
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyyapi
|
|
3
|
-
Version: 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`)
|
|
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
|
-
|
|
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[
|
|
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
|
-
|
|
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(**
|
|
313
|
+
dynamic_prompt = await func(**user_kwargs)
|
|
268
314
|
else:
|
|
269
|
-
dynamic_prompt = func(**
|
|
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=
|
|
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
|
|
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(
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|