Skip to content

TokenRollAI/yapi

Repository files navigation

yapi

PyPI Python License: MIT

中文文档请见 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 unhyphenated yapi was taken by a 2018 project). Import path is still yapi.

Install

pip install pyyapi

Python 3.12+ required.

Quick start

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 --reload

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.

Open http://localhost:8000/docs for the auto-generated OpenAPI UI.

Mixing native FastAPI routes with prompt routes

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."""

Configuration

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.).

YAPI_MODEL (required for the default runner)

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 network

Unset → constructor emits a YapiUsageWarning, first request returns HTTP 500.

⚠️ 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, …).

Provider credentials (read directly by PydanticAI)

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

Example .env (DeepSeek)

YAPI_MODEL=openai:deepseek-chat
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=https://api.deepseek.com/v1
uv run uvicorn examples.wish_api:app --reload --env-file .env

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.

How a prompt route runs

For each request to a router.prompt.* route, yapi:

  1. parses path/query/header/cookie/body parameters via the function signature (FastAPI semantics, plus a single BaseModel request body),
  2. calls your function (sync or async def) to optionally produce a dynamic prompt (the function's return value, must be None or str),
  3. composes the final system prompt from: response-model docstring + function docstring + dynamic prompt,
  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,
  5. validates the agent's output against your return annotation and serializes via FastAPI.

Contract (hard rules)

Applies inside router.prompt.*:

  • Return annotation must be a BaseModel subclass.
  • At most one parameter may be a BaseModel (the request body). Supports both req: WishIn and req: Annotated[WishIn, Body()].
  • Other parameters must be one of:
    • Depends(...) default or Annotated[T, Depends(...)]
    • Annotated[T, Query()/Header()/Cookie()/Path()/Form()/File()] or the equivalent = Query(...) default
  • *args / **kwargs are rejected at decoration time.
  • Function body must return None or a str (the dynamic prompt). Anything else raises at request time.
  • async def is 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.

Prompt context

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; BaseModelmodel_dump_json(); dict/list/tuplejson.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.

Dependency injection

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"

Custom agent runner

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.

Development

uv sync --extra dev
uv run pytest
uv run uvicorn examples.wish_api:app --reload

License

MIT


LINUX DO — A New Ideal Community

About

Stop coding, start yapping

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages