Пара Go-плагинов для bifrost, которые short-circuit’ят chat-completion запросы с response_format: json_schema — возвращают синтетический JSON по схеме без реального LLM-вызова. Заменяют утерянный TS apps/mock-llm-server (исходники сохранились в dangling commit ae3728e8 в bloodgpt-for-business, BG-1068, 2026-03-30 — extracted в /tmp/old-mock-llm/).

Назначение совпадает с research-направлением, зафиксированным в bifrost: ускорить e2e-тесты продуктовой оболочки (UI flow, ingest pipeline) без costs/latency реальных LLM. Плагин-mocker от upstream maximhq/bifrost тоже существует, но возвращает только canned strings — не поддерживает response_format: json_schema, а BloodGPT pipeline опирается на structured output. Поэтому свои плагины.

Где живёт в коде

  • Репо: Realai-plus/bifrost-plugins/home/i/JOBS/BloodGPT/bifrost-plugins/ локально (на 2026-05-07 ещё не запушен в GitHub)
  • schema-mocker/ — domain-agnostic. plugin.go (PreLLMHook short-circuit), schema_faker.go (рекурсия по schema), preset.go (registry), presets/medical/medical.go (BloodGPT-flavoured biomarker substitution)
  • fhir-postprocessor/ — BloodGPT-specific Post-hook, патчит valueQuantity в FHIR oneOf value[x]
  • cmd/bifrost-with-plugins/main.go — thin fork upstream bifrost-http, регистрирует плагины через SyncLoadedPlugin после Bootstrap()
  • Stage values будут редактироваться в INFRA/argocd-applications/common/bifrost-proxy/values.yaml — образ europe-west4-docker.pkg.dev/bg-stage-infra/docker-stage/bifrost-with-plugins:vX (на 2026-05-07 ещё не задеплоено)
  • Pinned bifrost core: v1.5.8. Bump core → пересборка UI + audit schema package

schema-mocker

Перехватывает запросы где chat.params.ResponseFormat.json_schema.schema not nil. Рекурсивно генерирует JSON по schema:

Schema constructПоведение
type: string/number/integer/boolean/nullgofakeit primitive (с учётом min/max/length)
type: object, propertiesобъект со всеми полями (alwaysFakeOptionals semantic)
type: array, itemsN items, N в [min_items, max_items]
enum: [...]случайное из перечисления
oneOf/anyOfслучайная одна ветка → recurse
allOfmerge всех вариантов → recurse
format: date/email/uuid/uriсоответствующий gofakeit
const: Xвозвращает X

response_format: {"type":"text"} или {"type":"json_object"} без schema — пропускается through (расценивается как not-a-schema, проверяется наличие properties/items/enum/oneOf/anyOf/allOf).

Domain presets

config.domain_preset указывает на регистрируемый preset который возвращает field-name → generator map. Вместо случайных латинских слов схема {name: string, unit: string, interpretation: string} под medical preset вернёт {name:"Iron", unit:"mg/dL", interpretation:"Low, may indicate deficiency"}.

Medical preset (presets/medical/medical.go) hardcoded списки портированы из старого TS-сервера: 47 биомаркеров, 17 единиц, 6 статусов, 10 интерпретаций, 8 рекомендаций, 10 категорий, 3 уровня urgency. Поля распознаются через lowercased имя: name/biomarker/marker/testname/param_name/parameter → биомаркеры, unit/units → единицы, и т.д.

Preset регистрируется через init() в своём пакете. Чтобы получить generic build без BloodGPT-знания — убрать blank import _ "schema-mocker/presets/medical" из main.go. Это keeps schema-mocker шиппабельным upstream.

Carry-over: добавить presets/legal, presets/finance если понадобятся другие домены — паттерн extension’а через init() уже заложен.

fhir-postprocessor

schema-mocker не знает что в prompt’е был Parameter_Info JSON с ParameterValue/Unit — он просто видит schema. Для FHIR-полей это критично: oneOf valueQuantity/valueBoolean/dataAbsentReason faker может выбрать valueBoolean: false для числового параметра, и pipeline молча drop’ает наблюдение.

Plugin chain: fhir-postprocessor Pre extract’ит ParameterValue и Unit из user-message (regex по "ParameterValue"\s*:\s*([\d.]+) и "Unit"\s*:\s*"([^"]+)"), stash’ит в BifrostContext. После того как schema-mocker short-circuit’нул и вернул response — fhir-postprocessor Post парсит JSON content, и:

  • если fhir_value.dataAbsentReason присутствует → НЕ трогает (legitimate absent case, TS-parity)
  • если fhir_value.valueQuantity есть → patches value/unit/code/system extracted значениями
  • если выпала другая ветка (valueBoolean / valueString / valueRatio) → overwrites целиком на valueQuantity с extracted values (UCUM http://unitsofmeasure.org)

Plugin безопасен no-op’ом: если в response нет fhir_value поля, ничего не делает. Можно держать enabled даже когда mock не активен.

Toggle layers

Три уровня контроля, разная гранулярность:

УровеньКтоГдеЭффект
Per-requestApp-разработчикheader X-Bifrost-Skip-Mock: 1|true|yes|onЭтот один запрос идёт в real LLM. Default = mock active
Per-modelDevOps в config.jsonschema-mocker.config.models: ["gpt-5.2-mock", ...]Какие model-имена ловит mock; пустой list = матчит все
Plugin-wideOperatorenabled: false в values.yaml или PUT /api/plugins/<name> {"enabled":false}Целиком on/off

Header реализован через schemas.BifrostContextKeyRequestHeaders — bifrost auto-populates этот ключ map’ом всех incoming HTTP-headers (lowercased). Это не custom mechanism, а тот же путь которым upstream governance/telemetry/logging плагины читают per-request data.

Re-enable через API сломан

PUT {"enabled":false} работает (verified). PUT {"enabled":true} после disable ломается: bifrost reload вызывает loadBuiltinPlugin(name, ...), не находит наш плагин в built-in switch, ставит status: error. Workaround: kubectl rollout restart deploy/bifrost-proxy — наш registerCustomPlugins после Bootstrap’а перепрописывает плагин.

Чистая починка — custom PluginLoader который knows our plugin names, ~50 строк Go, зафиксировано как open. См. bifrost-custom-plugin-loading для контекста.

Carry-over: реализовать customPluginLoader если operators начнут жаловаться на rollout-restart workaround. Пока — приемлемо.

Deployment

Стейдж сейчас крутит upstream maximhq/bifrost:latest. Чтобы переключиться на нашу сборку:

  1. Build образ: make docker IMAGE=europe-west4-docker.pkg.dev/bg-stage-infra/docker-stage/bifrost-with-plugins TAG=v0.0.1. Multi-stage Dockerfile клонит bifrost@core/v1.5.8, собирает upstream React UI через npm run build, embeds в Go-binary через //go:embed all:ui. Без этого UI на стейдже исчезнет — текущий же UI это upstream React app в upstream-binary
  2. docker push ...:v0.0.1 (gcloud auth configure-docker europe-west4-docker.pkg.dev если первый раз)
  3. Patch INFRA/argocd-applications/common/bifrost-proxy/values.yaml (ветка bloodgpt-stage-applications):
    • deployment.image.repository и tag на наш образ
    • В configmap.data.config.json добавить plugins: [{name: "fhir-postprocessor", enabled: true, order: 1, ...}, {name: "schema-mocker", enabled: true, order: 2, ...}]
    • Расширить openai models списком mock-вариантов (gpt-5.2-mock, gpt-4.1-mock, gpt-5-mini-mock)
  4. Commit + push → ArgoCD ApplicationSet auto-syncит за ~3 мин

Stage UI (kubectl port-forward svc/bifrost-proxy 8080:8080 -n common-bifrost-proxy) после rollout покажет оба плагина в списке Plugins, status active.

Plugin ordering критичен

fhir-postprocessor ДОЛЖЕН быть order: 1, schema-mocker order: 2. Bifrost вызывает PreLLMHook’и в порядке регистрации, PostLLMHook’и в обратном. Если schema-mocker идёт первым — он short-circuit’ит, и fhir-postprocessor.PreLLMHook никогда не выполнится → не успеет stash’нуть ParameterValue → Post-хук не сможет патчить. Это silent break — mock работает, но FHIR-значения random.

Carry-over: добавить guard в cmd/bifrost-with-plugins/registerCustomPlugins который assert’ит правильный order на старте. Сейчас полагается на оператора.

Configuration shape

{
  "plugins": [
    {
      "name": "fhir-postprocessor",
      "enabled": true,
      "order": 1,
      "config": {
        "enabled": true,
        "fhir_value_property": "fhir_value",
        "ucum_system": "http://unitsofmeasure.org"
      }
    },
    {
      "name": "schema-mocker",
      "enabled": true,
      "order": 2,
      "config": {
        "enabled": true,
        "domain_preset": "medical",
        "models": ["gpt-5.2-mock", "gpt-4.1-mock", "gpt-5-mini-mock"],
        "min_items": 1,
        "max_items": 3,
        "seed": 0
      }
    }
  ]
}

seed: 0 означает random per-process. Любое не-нулевое значение делает RNG детерминированным — полезно для smoke-тестов (одинаковые запросы дают одинаковые mock-ответы), вредно для нагрузки которая полагается на разнообразие.

Failure modes

  • Streaming не поддержан. PreLLMHook гейтится RequestType == ChatCompletionRequest. ChatCompletionStreamRequest проходит through к провайдеру. BloodGPT pipeline сейчас не стримит structured output — но если апп начнёт, mock молча перестанет ловить.
  • dataAbsentReason не патчится. TS-parity. Если в проде это вызовет проблемы (mock роняет наблюдения когда RNG выпадает на этой ветке) — нужен force_value_quantity flag в fhir-postprocessor.config.
  • unknown built-in plugin warning при Bootstrap’е suppressed filtering logger’ом в cmd/bifrost-with-plugins/logger.go. Но если добавить третий плагин — нужно обновить silenceFragments руками. Лёгкий drift.

Открытые вопросы

  • Какая стратегия model-gating’а в production: mock-suffix (gpt-5.2-mock), all-models (mock everything), или header-only — не зафиксировано. В values-diff заложили mock-suffix как самый безопасный default
  • CI (.github/workflows/build.yml) для авто-сборки образа на push в main — не написан
  • Field overrides через config (custom field-name → generator map) — нет такого config knob’а; только через код change

Связанные решения

Связано

  • bifrost — основной vendor-page; этот плагин-stack живёт сверху
  • parameter-range-type-prompt — schema’и которые мы здесь mock’аем
  • normalization-pipeline — потребитель FHIR-output’а, страдает первым если mock возвращает random valueQuantity

Источники

Источники: 1 2.

Сноски

  1. Bifrost docs (research), accessed 2026-05-17, https://docs.getbifrost.ai/plugins/writing-go-plugin.

  2. Bifrost issue про context-key pattern, accessed 2026-05-17, https://github.com/maximhq/bifrost/issues/2029.