Перейти к содержанию

5. Чей это запрос

Policy — это объект. Превратить в него HTTP-запрос и есть последний шаг, и именно про него у StageFlow намеренно нет своего мнения.

Авторизации во фреймворке нет и не будет. Ядро принимает готовую политику; токены, заголовки, сессии, клиенты и подписки — это то, к чему у каждой платформы уже есть свой подход, и подход фреймворка стал бы ещё одной вещью, с которой приходится бороться. В примере на всё про всё уходит сорок строк в app/auth.py, и настоящий бэкенд заменяет этот файл целиком, не трогая больше ничего.

Тарифы — это просто таблица

PLANS: dict[str, Policy] = {
    "full":  Policy(),                     # пример без ограничений
    "basic": Policy(stages=RULES_ONLY | PLUMBING, node_types={...}, limits=...),
    "pro":   Policy(node_types=None, limits=Limits(counters={"tokens": 200_000, ...})),
}

Настоящая платформа найдёт клиента и соберёт политику из его подписки. Слова «тариф», «basic» и «pro» — дело платформы, ровно как и прайс-лист, который превращает result.meters в счёт.

Одно из примера стоит перенять в любом случае: basic умеет что-то запускать. У него есть свой пайплайн, 06-rules-only.json, — весь бот целиком, но без единого обращения к модели. Тариф, на котором не работает вообще ничего, — это не дешёвый тариф, а сломанный, и пользователя он учит только тому, что продукт не работает.

Решает ключ доступа

AUTH_HEADER = os.environ.get("SF_AUTH_HEADER", "Authorization")
TOKENS = _parse_tokens(os.environ.get("SF_TOKENS", ""))   # "tok:plan,tok:plan"


def caller_plan(request: Request) -> str:
    if not TOKENS:                       # ничего не настроено: никого не различаем
        return OPEN_PLAN
    token = credential(request)
    if token is None:
        raise HTTPException(401, f"no credentials: send a token in the '{AUTH_HEADER}' header")
    plan = TOKENS.get(token)
    if plan is None:
        raise HTTPException(401, "the credentials were not recognised")
    return plan

Название заголовка вынесено в настройку, потому что бэкенды тут не сходятся во мнениях: Authorization: Bearer …, X-Api-Key: …, что-нибудь, что положил шлюз. Редактор такие вещи решать не должен. Если не настроено ничего, никакой авторизации нет вовсе и пример просто запускается и работает — в чём и смысл примера.

В редакторе это просто поле, и его значение проверяется тем же запросом, что и адрес. Так что неправильный токен оказывается неправильным на том же экране, где его можно исправить, а не на первом прогоне:

Экран подключения отказывает, поле с ключом раскрыто

Посмотреть тариф и работать на нём — разные вещи

Дальше идёт самая важная часть, потому что напрашивающееся здесь упрощение разрушает всё, что построили два предыдущих шага.

Читающие эндпоинты принимают ?plan= — от кого угодно и без всякой проверки:

@router.get("/meta")
async def meta(caller: CallerPlan, plan: ShownPlan = None) -> dict:
    shown = plan or caller
    policy = policy_for(shown)
    return {"api": 1, "plan": shown, "plan_source": source_of(plan),
            "plans": plan_names(), **capabilities(policy), "limits": _limits_of(policy)}

Название тарифа — не пропуск на него. Рисовать не значит запускать, а редактор, которому нужно авторизоваться, прежде чем погасить кнопку в палитре, никто настраивать не станет. Поэтому вопрос «а как этот граф выглядел бы на тарифе подешевле» может задать кто угодно. plans — готовый список названий, чтобы клиенту не пришлось хранить их у себя: знать их заранее он не может.

А вот запуск такого параметра не принимает:

@router.post("/run", status_code=201)
async def start_run(body: RunRequest, caller: CallerPlan) -> dict:
    if body.plan is not None and body.plan != caller:
        raise HTTPException(403, f"this graph was prepared for plan '{body.plan}', "
                                 f"and these credentials are on '{caller}'")
    run = runs.start(body.model_dump(), policy_for(caller))

body.plan едет в обратную сторону: это то, под какой тариф граф рисовали, а не просьба на нём запуститься. Если его прислали, расхождение можно назвать вслух — и это куда более полезный ответ, чем куча ошибок валидации про отдельные стадии:

Отказ, в котором названы оба тарифа

Прокиньте ?plan= ещё и в запуск — одна строка, выглядит совершенно безобидно — и каждый потолок из двух предыдущих шагов превратится в query-параметр. В примере check_pipelines.py проверяет, что этого никто не сделал: такое свойство ломается незаметно для любого теста эндпоинтов.

Что из этого делает редактор

Всё это собрано в один диалог — «File» → «Connection…» или клик по строке бэкенда в строке состояния:

Диалог подключения

В нём четыре вещи: адрес, ключ доступа, тариф, под который рисуем, и то, что ответил бэкенд. Смена адреса перезагружает страницу — на адресе стоит вся сессия. Ключ, наоборот, применяется на месте: токен протухает посреди работы, а отвергнутый откатывается, чтобы сессия уцелела. ?plan=basic в адресе самого редактора открывает его сразу в нужном виде, а строка состояния помечает предпросмотр как предпросмотр, чтобы его нельзя было принять за реальные права.

Где проходит граница

Фреймворк Платформа
что может содержать граф Policy какая именно политика кому достанется
сколько прогон может потратить Limits, счётчики, BudgetExceeded сколько стоит единица
кто прислал запрос — целиком
месячная квота, очередь, пул — целиком

Последняя строка важна не меньше остальных. concurrency: 8 ограничивает один прогон; сто прогонов — это восемьсот задач, а сколько прогонов существует одновременно, ядро знать не может. Допуск к запуску — очередь, пул на клиента, месячный бюджет — это дело платформы. Дело ядра — держать потолок одного прогона и честно доложить, во что тот обошёлся.


Вот и весь бэкенд: стадии, эндпоинты, политика, счётчики и ключ доступа. Готовое целиком лежит в stageflow-example — около шестисот строк на Python.