中文文档请见 README.zh-CN.md
Prompt-first declarative HTTP framework — write a normal Python function with a docstring, get an LLM-powered HTTP endpoint with structured JSON responses.
yapi is a thin layer on top of FastAPI and PydanticAI. PromptRouter is a true superset of fastapi.APIRouter: native routes work as-is, and prompt routes live in the router.prompt.* namespace.
Package name on PyPI is
pyyapi(the unhyphenatedyapiwas taken by a 2018 project). Import path is stillyapi.
pip install pyyapiPython 3.12+ required.
from fastapi import FastAPI
from pydantic import BaseModel
from yapi import PromptRouter
class WishIn(BaseModel):
user_id: str
wish: str
class WishOut(BaseModel):
"""You are a wish-granting entity. Decide whether to grant the wish."""
granted: bool
message: str
app = FastAPI(title="yapi showcase")
router = PromptRouter()
@router.prompt.post("/wish")
def make_a_wish(req: WishIn) -> WishOut:
"""Decide whether to grant the user's wish."""
app.include_router(router)Run it:
YAPI_MODEL=test uvicorn examples.wish_api:app --reloadYAPI_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.
Open http://localhost:8000/docs for the auto-generated OpenAPI UI.
PromptRouter is now a real APIRouter superset. .get/.post/... keep their FastAPI semantics; only router.prompt.* enters the LLM pipeline.
router = PromptRouter(prefix="/v1", tags=["wishes"])
@router.get("/health")
def health() -> dict:
return {"status": "ok"}
@router.prompt.post("/wish")
def make_a_wish(req: WishIn) -> WishOut:
"""Decide whether to grant the user's wish."""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.).
PydanticAI model string in provider:model form. Read once when PromptRouter() is constructed without an explicit agent_runner.
YAPI_MODEL=openai:gpt-4o # OpenAI
YAPI_MODEL=anthropic:claude-3-5-sonnet # Anthropic
YAPI_MODEL=openai:deepseek-chat # DeepSeek (OpenAI-compatible)
YAPI_MODEL=test # PydanticAI TestModel, no key, no networkUnset → constructor emits a YapiUsageWarning, first request returns HTTP 500.
⚠️ The model must support OpenAI Function Calling'stool_choiceparameter.yapirelies on PydanticAI's structured-output path, which forces the model to emit a tool call matching your responseBaseModel. Models that lacktool_choicesupport — most notably "reasoning / thinking" variants such asdeepseek-reasoner,deepseek-v4-flash,o1-preview/o1-mini, or any chat-only / completion-only checkpoint — will return HTTP 500 with aModelHTTPErrorat the first request. Pick a model whose API docs explicitly support function calling (gpt-4o,gpt-4o-mini,claude-3-5-sonnet,deepseek-chat, …).
yapi does not validate or even look at these — they are consumed by the underlying PydanticAI provider via os.environ:
| Provider | Env vars |
|---|---|
| OpenAI | OPENAI_API_KEY |
| OpenAI-compatible endpoints (DeepSeek, Azure OpenAI, OneAPI, local servers, …) | OPENAI_API_KEY + OPENAI_BASE_URL (e.g. https://api.deepseek.com/v1) |
| Anthropic | ANTHROPIC_API_KEY |
| Others (Google, Groq, Mistral, …) | See PydanticAI providers docs |
YAPI_MODEL=openai:deepseek-chat
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=https://api.deepseek.com/v1uv run uvicorn examples.wish_api:app --reload --env-file .envSame caveat as the warning above: DeepSeek's "thinking" models (
deepseek-reasoner,deepseek-v4-flash) rejecttool_choiceand won't work here. Usedeepseek-chat.
For each request to a router.prompt.* route, yapi:
- parses path/query/header/cookie/body parameters via the function signature (FastAPI semantics, plus a single
BaseModelrequest body), - calls your function (sync or
async def) to optionally produce a dynamic prompt (the function'sreturnvalue, must beNoneorstr), - composes the final system prompt from: response-model docstring + function docstring + dynamic prompt,
- invokes the configured
agent_runner(defaulting to a PydanticAIAgent) with aRunnerContextcontaining the prompt, request payload, injected fields, response model, path and method, - validates the agent's output against your return annotation and serializes via FastAPI.
Applies inside router.prompt.*:
- Return annotation must be a
BaseModelsubclass. - At most one parameter may be a
BaseModel(the request body). Supports bothreq: WishInandreq: Annotated[WishIn, Body()]. - Other parameters must be one of:
Depends(...)default orAnnotated[T, Depends(...)]Annotated[T, Query()/Header()/Cookie()/Path()/Form()/File()]or the equivalent= Query(...)default
*args/**kwargsare rejected at decoration time.- Function body must
returnNoneor astr(the dynamic prompt). Anything else raises at request time. async defis supported.
Decoration kwargs:
- Passed through to FastAPI:
tags,summary,description,status_code,deprecated,operation_id,name,include_in_schema,responses,openapi_extra. - Rejected at decoration time with
YapiDeclarationError:response_model,response_class,dependencies. - Any other unknown kwarg emits a
YapiUsageWarning.
Violations are raised as YapiDeclarationError at decoration time — broken routes fail at import, not at request time.
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:
from yapi import PromptContext, PromptRouter
router = PromptRouter()
@router.prompt.post("/wish")
def make_a_wish(req: WishIn, ctx: PromptContext) -> WishOut:
"""Decide whether to grant the user's wish."""
ctx.add_section("User Profile", {"vip": req.user_id.startswith("vip-")})
ctx.add_kv("user_id", req.user_id)
ctx.add(req.wish)yapi collects all segments and wraps them in <context>…</context> at the end of the system prompt:
You are the execution engine…
Decide whether to grant the user's wish.
<context>
# User Profile
{"vip": true}
user_id: vip-1
moon
</context>
Three methods:
| Method | Produces |
|---|---|
ctx.add(value) |
<serialized value> |
ctx.add_kv(key, value) |
{key}: <serialized value> |
ctx.add_section(name, body) |
# {name}\n<serialized body> |
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.
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).
State retrieval is out of scope for yapi. Fetch your data via Depends(...) and pass it to ctx.*. See examples/state_via_depends.py.
from fastapi import Depends
from typing import Annotated
def get_db():
...
@router.prompt.post("/wish")
def make_a_wish(
req: WishIn,
db: Annotated[Database, Depends(get_db)],
) -> WishOut:
"""..."""
return f"user has {db.balance(req.user_id)} wishes left"Implement the AgentRunner Protocol — any object with a .run(ctx: RunnerContext) -> dict | BaseModel method is accepted:
from yapi import AgentRunner, PromptRouter, RunnerContext
class MockRunner:
def run(self, ctx: RunnerContext) -> dict:
return {
"granted": "moon" not in ctx.request["wish"].lower(),
"message": f"path={ctx.path}",
}
router = PromptRouter(agent_runner=MockRunner())The legacy v2-style (*, prompt, request, injected, response_model) -> dict callable is still accepted (auto-adapted).
You can also inject a custom prompt_composer= to customize how the system prompt is assembled.
uv sync --extra dev
uv run pytest
uv run uvicorn examples.wish_api:app --reloadMIT