Перейти к основному содержимому

Доступ плагина к LLM

ctx.llm — это поддерживаемый способ для плагина выполнить LLM-вызов. Завершение чата, структурированное извлечение, синхронно, асинхронно, с изображениями или без — единый интерфейс, единый защитный шлюз, единые учётные данные хоста.

Плагины обращаются к этому, когда им нужно сделать что-то, что включает модель, но не является частью диалога агента. Хук, который переписывает ошибку инструмента в понятную для не-инженера форму. Адаптер шлюза, который переводит входящее сообщение перед постановкой в очередь. Slash-команда, которая суммирует длинную вставку. Запланированная задача, которая оценивает вчерашнюю активность и пишет одну строку на доску статуса. Предварительный фильтр, который решает, стоит ли вообще будить агента для этого сообщения.

Это задачи, в которые агент не должен быть вовлечён. Им нужен один LLM-вызов, типизированный ответ — и всё.

Самый простой вызов​

result = ctx.llm.complete(messages=[{"role": "user", "content": "ping"}])
return result.text

Это весь API в одной строке. Никаких ключей, никакой конфигурации провайдера, никакой инициализации SDK. Плагин работает с тем провайдером и моделью, которые использует пользователь — когда он меняет провайдера, плагин автоматически следует за ним.

Более полный пример чата​

result = ctx.llm.complete(
messages=[
{"role": "system", "content": "Перепиши ошибки как одно короткое предложение, по которому не-инженер может действовать."},
{"role": "user", "content": traceback_text},
],
max_tokens=64,
purpose="hooks.error-rewrite",
)
return result.text

purpose — это свободная строка для аудита — она отображается в agent.log и в result.audit, чтобы операторы могли видеть, какой плагин сделал какой вызов. Опционально, но рекомендуется для всего, что вызывается часто.

Структурированный вывод​

Когда плагину нужен типизированный ответ, переключайтесь на структурированный путь:

result = ctx.llm.complete_structured(
instructions="Оцени срочность этого ответа поддержки (0–1) и выбери категорию.",
input=[{"type": "text", "text": message_body}],
json_schema=TRIAGE_SCHEMA,
purpose="support.triage",
temperature=0.0,
max_tokens=128,
)

if result.parsed["urgency"] > 0.8:
await dispatch_to_oncall(result.parsed["category"], message_body)

Хост запрашивает JSON-вывод от провайдера, локально парсит его как запасной вариант, проверяет вашу схему, если установлен jsonschema, и возвращает Python-объект в result.parsed. Если модель не смогла сгенерировать валидный JSON, result.parsed равен None, а result.text содержит сырой ответ.

Что даёт этот путь​

  • Один вызов, четыре формы. complete() для чата, complete_structured() для типизированного JSON, acomplete() и acomplete_structured() для asyncio. Те же аргументы, те же объекты результата.
  • Учётные данные хоста. OAuth-токены, процедуры обновления, пул учётных данных, вспомогательные переопределения для задач — все концепции учётных данных, которые уже есть в VibeOS, применимы. Плагин никогда не видит токен; хост атрибутирует вызов обратно через result.audit.
  • Ограниченность. Один синхронный или асинхронный вызов. Никакой потоковой передачи, никаких циклов инструментов, никакого состояния диалога для управления. Задайте входные данные, получите результат, вернитесь.
  • Защитный шлюз с отказом по умолчанию. Плагин, который вы никогда не настраивали, не может выбрать собственного провайдера, модель, агента или сохранённые учётные данные. Позиция по умолчанию — «использовать то, что использует пользователь». Операторы подписываются на конкретные переопределения, для каждого плагина, в config.yaml.

Быстрый старт​

Ниже приведены два полных плагина — один для чата, один для структурированного вывода. Оба работают внутри одной функции register(ctx) и не требуют никакой внешней конфигурации для работы с любой моделью, активной у пользователя.

Завершение чата — /tldr​

def register(ctx):
ctx.register_command(
name="tldr",
handler=lambda raw: _tldr(ctx, raw),
description="Суммируй предоставленный текст в одном абзаце.",
args_hint="<text>",
)


def _tldr(ctx, raw_args: str) -> str:
text = raw_args.strip()
if not text:
return "Использование: /tldr <текст для суммирования>"
result = ctx.llm.complete(
messages=[
{"role": "system",
"content": "Суммируй текст пользователя в одном сжатом абзаце. Без предисловий."},
{"role": "user", "content": text},
],
max_tokens=256,
temperature=0.3,
purpose="tldr",
)
return result.text

result.text — это ответ модели; result.usage содержит количество токенов; result.provider и result.model содержат атрибуцию.

Структурированное извлечение — /paste-to-tasks​

def register(ctx):
ctx.register_command(
name="paste-to-tasks",
handler=lambda raw: _paste_to_tasks(ctx, raw),
description="Преврати произвольные заметки с встречи в структурированные задачи.",
args_hint="<text>",
)


_TASKS_SCHEMA = {
"type": "object",
"properties": {
"tasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"action": {"type": "string"},
"due": {"type": "string", "description": "ISO-дата или пусто"},
},
"required": ["action"],
},
},
},
"required": ["tasks"],
}


def _paste_to_tasks(ctx, raw_args: str) -> str:
if not raw_args.strip():
return "Использование: /paste-to-tasks <заметки с встречи>"
result = ctx.llm.complete_structured(
instructions=(
"Извлеки конкретные пункты действий из этих заметок с встречи. "
"Одна задача на одну строку с действием. Если владелец не указан, оставь 'owner' пустым."
),
input=[{"type": "text", "text": raw_args}],
json_schema=_TASKS_SCHEMA,
schema_name="meeting.tasks",
purpose="paste-to-tasks",
temperature=0.0,
max_tokens=512,
)
if result.parsed is None:
return f"Не удалось разобрать ответ. Сырой вывод:\n{result.text}"
lines = [f"- [{t.get('owner') or '?'}] {t['action']}" for t in result.parsed["tasks"]]
return "\n".join(lines) or "(задачи не найдены)"

Третий рабочий пример, на этот раз с вводом изображения, находится в репозитории vibeos-example-plugins (сопутствующий репозиторий для эталонных плагинов — не входит в vibeos-agent). Для асинхронного интерфейса (acomplete() / acomplete_structured() с asyncio.gather()) смотрите plugin-llm-async-example в том же репозитории.

Когда что использовать​

Вам нужно…Используйте
Ответ в свободной текстовой форме (перевод, суммаризация, переписывание, генерация)complete()
Многовитковый промпт (системный + несколько примеров + пользовательский)complete()
Типизированный словарь, проверенный по схемеcomplete_structured()
Ввод изображения или текста с типизированным словарём на выходеcomplete_structured()
Тот же вызов из асинхронного кода (адаптеры шлюзов, асинхронные хуки)acomplete() / acomplete_structured()

Всё остальное — выбор провайдера, разрешение модели, аутентификация, запасной вариант, тайм-аут, маршрутизация изображений — одинаково для всех четырёх.

API-интерфейс​

ctx.llm — это экземпляр agent.plugin_llm.PluginLlm.

complete()​

result = ctx.llm.complete(
messages=[{"role": "user", "content": "Привет"}],
provider=None, # опционально, с ограничением — ID провайдера VibeOS (например, "openrouter")
model=None, # опционально, с ограничением — любая строка, которую ожидает провайдер
temperature=None,
max_tokens=None,
timeout=None, # секунды
agent_id=None, # опционально, с ограничением
profile=None, # опционально, с ограничением — явное имя профиля аутентификации
purpose="опциональная-строка-аудита",
)
# → PluginLlmCompleteResult(text, provider, model, agent_id, usage, audit)

Обычное завершение чата. messages — это стандартная форма OpenAI: список словарей {"role": "...", "content": "..."}. Многовитковые промпты (системный + несколько пар пользователь/ассистент + финальный пользователь) работают точно так же, как с OpenAI SDK.

provider= и model= независимы и следуют той же форме, что и основная конфигурация хоста (model.provider + model.model). Установите только model=, чтобы использовать активного провайдера пользователя с другой моделью. Установите оба, чтобы полностью сменить провайдера. Любой аргумент без согласия оператора вызывает PluginLlmTrustError.

complete_structured()​

result = ctx.llm.complete_structured(
instructions="Что вы хотите извлечь.",
input=[
{"type": "text", "text": "..."},
{"type": "image", "data": b"...", "mime_type": "image/png"},
{"type": "image", "url": "https://..."},
],
json_schema={...}, # опционально — запускает парсинг результата + валидацию
json_mode=False, # установите True без схемы, чтобы всё равно запросить JSON
schema_name=None, # опциональное человекочитаемое имя схемы
system_prompt=None,
provider=None, # опционально, с ограничением
model=None, # опционально, с ограничением
temperature=None,
max_tokens=None,
timeout=None,
agent_id=None,
profile=None,
purpose=None,
)
# → PluginLlmStructuredResult(text, provider, model, agent_id,
# usage, parsed, content_type, audit)

Входные данные — это типизированные текстовые или графические блоки (сырые байты автоматически кодируются в base64 как URL data:). Когда указаны json_schema или json_mode=True, хост запрашивает JSON-вывод через response_format, локально парсит его как запасной вариант и проверяет по вашей схеме, если установлен jsonschema.

  • result.content_type == "json" — result.parsed — это Python-объект, соответствующий вашей схеме.
  • result.content_type == "text" — парсинг или валидация не удались; проверьте result.text для сырого ответа модели.

Асинхронный режим​

result = await ctx.llm.acomplete(messages=...)
result = await ctx.llm.acomplete_structured(instructions=..., input=...)

Те же аргументы и типы результатов, что и у их синхронных аналогов. Используйте их из адаптеров шлюзов, асинхронных хуков или любого кода плагина, уже работающего на цикле asyncio.

Атрибуты результата​

@dataclass
class PluginLlmCompleteResult:
text: str # ответ ассистента
provider: str # например, "openrouter", "anthropic"
model: str # то, что провайдер вернул для этого вызова
agent_id: str # чья модель/аутентификация использовалась
usage: PluginLlmUsage # токены + кэш + оценка стоимости
audit: Dict[str, Any] # plugin_id, purpose, profile

@dataclass
class PluginLlmStructuredResult(PluginLlmCompleteResult):
parsed: Optional[Any] # JSON-объект, когда content_type == "json"
content_type: str # "json" или "text"
# audit также содержит schema_name, если указано

usage содержит input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens и cost_usd, когда провайдер возвращает эти поля.

Защитный шлюз​

Поведение по умолчанию — отказ. Без блока конфигурации plugins.entries плагин может:

  • выполнять любой из четырёх методов с активным провайдером и моделью пользователя;
  • устанавливать аргументы формирования запроса (temperature, max_tokens, timeout, system_prompt, purpose, messages, instructions, input, json_schema);

…и это всё. Аргументы provider=, model=, agent_id= и profile= вызывают PluginLlmTrustError, пока оператор не даст согласие.

Большинству плагинов этот раздел никогда не понадобится. Плагин, который просто вызывает ctx.llm.complete(messages=...) без переопределений, работает с тем, что активно у пользователя, и не требует настройки. Блок ниже актуален только тогда, когда плагин специально хочет закрепиться за другой моделью или провайдером, отличным от пользовательского.

plugins:
entries:
my-plugin:
llm:
# Разрешить этому плагину выбирать другого провайдера VibeOS
# (должен быть тем, о котором VibeOS уже знает — те же имена,
# что и в `vibeos model` и config.yaml model.provider).
allow_provider_override: true

# Опционально ограничить список провайдеров. Используйте ["*"] для любых.
allowed_providers:
- openrouter
- anthropic

# Разрешить этому плагину запрашивать конкретную модель.
allow_model_override: true

# Опционально ограничить список моделей. Используйте ["*"] для любых.
# Модели сопоставляются буквально с той строкой, которую отправляет
# плагин — VibeOS ничего не ищет.
allowed_models:
- openai/gpt-4o-mini
- anthropic/claude-3-5-haiku

# Разрешить вызовы от имени другого агента (редко).
allow_agent_id_override: false

# Разрешить плагину запрашивать конкретный сохранённый профиль
# аутентификации (например, другую учётную запись OAuth у того же
# провайдера).
allow_profile_override: false

Идентификатор плагина — это поле name: манифеста для плоских плагинов или ключ, производный от пути, для вложенных плагинов (image_gen/openai, memory/honcho и т.д.).

Что обеспечивает шлюз​

ПереопределениеПо умолчаниюКлюч конфигурации
provider=запрещеноallow_provider_override: true
↳ белый список—allowed_providers: [...]
model=запрещеноallow_model_override: true
↳ белый список—allowed_models: [...]
agent_id=запрещеноallow_agent_id_override: true
profile=запрещеноallow_profile_override: true

Каждое переопределение независимо ограничено. Предоставление allow_model_override не предоставляет также allow_provider_override — плагину, которому доверяют выбор модели, всё равно запрещено менять провайдера, если он не получит также разрешение на провайдера.

Что шлюзу НЕ нужно обеспечивать​

  • Аргументы формирования запроса — temperature, max_tokens, timeout, system_prompt, purpose, messages, instructions, input, json_schema, schema_name, json_mode — всегда разрешены; они не выбирают учётные данные или маршруты.
  • Позиция отказа по умолчанию означает, что ненастроенный плагин всё равно может выполнять полезную работу — он просто работает с активным провайдером и моделью. Операторам нужно думать о plugins.entries только для плагинов, которым нужна более точная маршрутизация.

Что принадлежит хосту​

Полный список того, что ctx.llm делает для плагина, чтобы вам не пришлось:

  • Разрешение провайдера. Читает model.provider + model.model из конфигурации пользователя (или явные переопределения, если им доверяют).
  • Аутентификация. Извлекает API-ключи, OAuth-токены или токены обновления из ~/.vibeos/auth.json / env, включая пул учётных данных, если он настроен. Плагин их никогда не видит.
  • Маршрутизация изображений. Когда предоставлен ввод изображения и активная текстовая модель пользователя является только текстовой, хост автоматически переключается на настроенную модель для изображений.
  • Цепочка запасных вариантов. Если основной провайдер пользователя возвращает 5xx или 429, запрос проходит через обычную цепочку запасных вариантов VibeOS с учётом агрегатора, прежде чем вернуть ошибку плагину.
  • Тайм-аут. Соблюдает ваш аргумент timeout=, возвращаясь к конфигурации auxiliary.&lt;task&gt;.timeout или глобальному значению по умолчанию для вспомогательных задач.
  • Формирование JSON. Отправляет response_format провайдеру, когда вы запрашиваете JSON, затем повторно парсит локально из ответа, заключённого в код-фенс, если провайдер вернул такой.
  • Валидация схемы. Проверяет по вашей json_schema, если установлен jsonschema; записывает отладочную строку и пропускает строгую валидацию в противном случае.
  • Журнал аудита. Каждый вызов записывает одну строку INFO в agent.log с идентификатором плагина, провайдером/моделью, назначением и общим количеством токенов.

Что принадлежит плагину​

  • Форма запроса. messages для чата, instructions + input для структурированного вывода. Плагин строит промпт; хост его выполняет.
  • Схема. Любая форма, которую вы хотите получить. Хост не угадывает её за вас.
  • Обработка ошибок. complete_structured() вызывает ValueError при пустых входных данных и при ошибке валидации схемы. PluginLlmTrustError срабатывает, когда защитный шлюз отклоняет переопределение. Всё остальное (5xx провайдера, отсутствие настроенных учётных данных, тайм-аут) вызывает то, что вызывает auxiliary_client.call_llm().
  • Стоимость. Каждый вызов выполняется с использованием платного провайдера пользователя. Не зацикливайтесь на complete() для каждого сообщения шлюза, не думая о расходе токенов.

Где это вписывается в поверхность плагина​

Существующие методы ctx.* расширяют существующую подсистему VibeOS:

| ctx.register_tool | добавляет инструмент, который может вызывать агент | | ctx.register_platform | подключает новый адаптер шлюза | | ctx.register_image_gen_provider | заменяет бэкенд генерации изображений | | ctx.register_memory_provider | заменяет бэкенд памяти | | ctx.register_context_engine | заменяет компрессор контекста | | ctx.register_hook | наблюдает за событием жизненного цикла |

ctx.llm — это первая поверхность, которая позволяет плагину запускать ту же модель, с которой общается пользователь, вне основного канала, без всего вышеперечисленного. Это его единственная задача. Если вашему плагину нужно зарегистрировать инструмент, который вызывает агент, используйте register_tool. Если ему нужно реагировать на событие жизненного цикла, используйте register_hook. Если ему нужно сделать собственный вызов модели — по любой причине, структурированный или нет — используйте ctx.llm.

Ссылки​

  • Реализация: agent/plugin_llm.py
  • Тесты: tests/agent/test_plugin_llm.py
  • Эталонные плагины (сопутствующий репозиторий):
    • plugin-llm-example — синхронное структурированное извлечение с вводом изображения
    • plugin-llm-async-example — асинхронный режим с asyncio.gather()
  • Вспомогательный клиент (движок под капотом): смотрите Provider Runtime.