Пара 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в FHIRoneOfvalue[x]cmd/bifrost-with-plugins/main.go— thin fork upstreambifrost-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/null | gofakeit primitive (с учётом min/max/length) |
type: object, properties | объект со всеми полями (alwaysFakeOptionals semantic) |
type: array, items | N items, N в [min_items, max_items] |
enum: [...] | случайное из перечисления |
oneOf/anyOf | случайная одна ветка → recurse |
allOf | merge всех вариантов → 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есть → patchesvalue/unit/code/systemextracted значениями - если выпала другая ветка (valueBoolean / valueString / valueRatio) → overwrites целиком на
valueQuantityс extracted values (UCUMhttp://unitsofmeasure.org)
Plugin безопасен no-op’ом: если в response нет fhir_value поля, ничего не делает. Можно держать enabled даже когда mock не активен.
Toggle layers
Три уровня контроля, разная гранулярность:
| Уровень | Кто | Где | Эффект |
|---|---|---|---|
| Per-request | App-разработчик | header X-Bifrost-Skip-Mock: 1|true|yes|on | Этот один запрос идёт в real LLM. Default = mock active |
| Per-model | DevOps в config.json | schema-mocker.config.models: ["gpt-5.2-mock", ...] | Какие model-имена ловит mock; пустой list = матчит все |
| Plugin-wide | Operator | enabled: 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. Чтобы переключиться на нашу сборку:
- 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 docker push ...:v0.0.1(gcloud auth configure-docker europe-west4-docker.pkg.devесли первый раз)- 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)
- 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_quantityflag вfhir-postprocessor.config. unknown built-in pluginwarning при 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-custom-plugin-loading — почему SyncLoadedPlugin вместо
.so— active - bifrost-mock-strategy — split на schema-mocker (generic) + fhir-postprocessor (BloodGPT-specific) — active
Связано
- bifrost — основной vendor-page; этот плагин-stack живёт сверху
- parameter-range-type-prompt — schema’и которые мы здесь mock’аем
- normalization-pipeline — потребитель FHIR-output’а, страдает первым если mock возвращает random
valueQuantity
Источники
Сноски
-
Bifrost docs (research), accessed 2026-05-17, https://docs.getbifrost.ai/plugins/writing-go-plugin. ↩
-
Bifrost issue про context-key pattern, accessed 2026-05-17, https://github.com/maximhq/bifrost/issues/2029. ↩