| name | llm-client |
| description | LLM-специфика I/O-объекта взаимодействия с моделью (Anthropic / OpenAI-совместимые) — выбор протокола (OpenAI chat completions), structured output через response_format, фан-аут JTBD-ролей, маркер role в стабе, фиксация реальных ответов. Применять вместе с http-io (общая дисциплина исходящего HTTP — бюджеты нагрузки/payload, спека провайдера, режимы отказа, стаб). Не применять для выбора модели под задачу — это отдельное решение. |
LLM-клиент — специфика взаимодействия с моделью
Общая дисциплина исходящего HTTP — в скилле http-io:
два бюджета (нагрузки и payload), curl-проба, спека провайдера (OpenAPI/AsyncAPI),
пацинг/бэкофф, классы отказа transient/permanent/quota, стаб в Compose, мост
«от curl к тестам с учётом формул». Этот скилл — LLM-частности поверх той
дисциплины: протокол, response_format, фан-аут JTBD-ролей, фиксация ответов.
Зафиксировано на реальной реализации LLMClient (internal/slice/fitness/io.go,
devlog/01-llm-client-lessons.md).
Протокол: OpenAI-совместимый слой, не нативный API
Используем OpenAI chat completions format (POST /v1/chat/completions)
даже для Anthropic. Причина: один код работает с любым провайдером —
Anthropic, OpenAI, локальный Ollama, корпоративный прокси.
Anthropic base URL: https://api.anthropic.com/v1
Endpoint: POST {base_url}/chat/completions
Auth header: Authorization: Bearer <key>
Нативный Anthropic API (POST /v1/messages, заголовок x-api-key,
поле anthropic-version) — не используем. Он даёт больше возможностей
(thinking, citations), но ломает провайдер-агностичность.
Ловушка: дефолтный baseURL = "https://api.anthropic.com" без /v1
даёт POST https://api.anthropic.com/chat/completions — 404 или 400.
Всегда включать /v1 в baseURL (curl-проба ловит это до кода — см. http-io).
Машинная спека этого среза провайдера —
api-specification/providers/anthropic-openai-compat.openapi.yaml
(из неё выводятся структуры клиента и стаб; см. http-io → «Спека провайдера»).
Structured output — обязателен
Без response_format модель оборачивает JSON в markdown-блок
(```json ... ```), даже при явной инструкции «только JSON».
Это ломает парсинг — формат вывода задаётся API-механизмом, не инструкцией в тексте.
Правильный запрос для Anthropic (OpenAI-слой):
{
"model": "claude-sonnet-4-6",
"messages": [...],
"max_tokens": 4096,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "verdict",
"strict": true,
"schema": {
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["PASS", "FAIL", "PARTIAL"]
Ловушки response_format у Anthropic (три итерации curl, devlog ошибка 6):
"type": "json_object" → 400 Input should be 'json_schema'
- Без
"strict": true → 400 Field required
- Без
"additionalProperties": false → 400 при некоторых схемах
С json_schema + strict: true + additionalProperties: false модель возвращает
чистый JSON строкой в choices[0].message.content без обёрток.
Fallback-парсинг оставляем как второй эшелон — на случай провайдера, не
поддерживающего response_format:
func extractJSON(s string) string {
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start == -1 || end == -1 || end < start {
return s
}
return s[start : end+1]
}
extractJSON юнит-тестируется на фикстурах «чистый JSON» и «json-обёртка»
(см. http-io → «От curl к тестам»).
Фан-аут JTBD-ролей
Ask(JTBDPromptSet) -> []LLMVerdict делает N независимых вызовов (4 роли:
maintainer, consumer, manager, agent), каждый — один и тот же корпус docs под
своим промптом роли. Результаты не усредняются — четыре независимых score.
Это N последовательных вызовов → попадает прямо под бюджет нагрузки и пацинг из
http-io: N × токены/вызов ≤ TPM, пауза/бэкофф между
вызовами. Промпты ролей — в конфиге (prompts:), дорабатываются без пересборки.
Стаб: различение ролей маркером role:<key>
Общая механика стаба (отдельный HTTP-сервис в Compose, тот же эндпоинт,
POST /control для режима) — в http-io. LLM-специфика:
стаб различает вердикт по роли через маркер role:<key> в теле промпта.
Дефолтные промпты обязаны нести этот маркер.
func detectRole(r *http.Request) string {
b, _ := io.ReadAll(r.Body)
body := strings.ToLower(string(b))
for _, role := range []string{"maintainer", "consumer", "manager", "agent"} {
if strings.Contains(body, "role:"+role) {
return role
}
}
return "maintainer"
}
Реальный провайдер маркер игнорирует; стаб реагирует детерминированно. Это даёт
специфицировать независимость и не-усреднение четырёх JTBD-score. Дополнительный
LLM-режим стаба markdown_fenced (вердикт в json-обёртке) специфицирует,
что клиент обязан справляться с ответом без response_format.
Тестовые данные: фиксировать реальные ответы модели
При первом успешном прогоне на реальной модели — сохранить ответ в
component-tests/testdata/real-responses/<repo>-<slice>.json:
{
"_meta": {
"source": "real Sonnet (claude-sonnet-4-6)",
"repo": "ubik-life/passkey-demo-api",
"docs_checked": ["README.md", "CLAUDE.md", "CONTRIBUTING.md", "AGENTS.md"],
"date": "YYYY-MM-DD"
},
"jtbd": { ... }
}
На основе этих данных — отдельный режим стаба (passkey, bad_repo): стаб
возвращает зафиксированные вердикты детерминированно, воспроизводя поведение
реальной модели без сети. Покрыть минимум два варианта:
- Хорошая репа (часть ролей FAIL/PARTIAL — это норма, не провал);
- Плохая репа (все роли FAIL, score 2-3, gaps про отсутствие содержимого).
Чеклист LLM-специфики
(общий чеклист исходящего HTTP — в http-io)
Перед коммитом
Два обязательных шага — системные ошибки CI из практики (общие для всех слайсов,
здесь — потому что новая зависимость и I/O появились именно тут):
1. gofmt
gofmt -l ./internal/slice/<name>/
Проверять все .go-файлы слайса перед каждым коммитом. gofmt-ошибка в CI —
признак пропущенного шага, а не нового правила.
2. go.sum в Dockerfile
Если в слайсе появилась новая зависимость (go.mod изменился):
Без go.sum в образе go build падает с
missing go.sum entry for module providing package <dep>
даже если go.sum есть в репозитории.