From b26e059f010ed226cbffebf276456177400c9d7b Mon Sep 17 00:00:00 2001 From: Devbox Agent Date: Fri, 26 Jun 2026 17:08:38 -0700 Subject: [PATCH 01/13] feat(bifrost): route OpenRouter policy from model suffixes --- config/bifrost.config.json.tmpl | 9 ++++ config/openrouter-presets.json | 14 +++++- docker-compose.yml | 4 +- docs/CHANGELOG.md | 37 +++++++++++++++ docs/NOTES.md | 83 +++++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 2 deletions(-) diff --git a/config/bifrost.config.json.tmpl b/config/bifrost.config.json.tmpl index c8a66d6..c80dfa5 100644 --- a/config/bifrost.config.json.tmpl +++ b/config/bifrost.config.json.tmpl @@ -44,6 +44,15 @@ ] }, + "plugins": [ + { + "enabled": true, + "name": "model-policy-suffix", + "path": "/app/plugins/model-policy-suffix.so", + "version": 1 + } + ], + "providers": { "openrouter": { "keys": [ diff --git a/config/openrouter-presets.json b/config/openrouter-presets.json index de4e33a..552bb9b 100644 --- a/config/openrouter-presets.json +++ b/config/openrouter-presets.json @@ -1,5 +1,5 @@ { - "_comment": "Declarative OpenRouter presets. Sync to the OpenRouter account with `just openrouter-presets-sync` (tools/openrouter_presets.py). Presets bake provider routing SERVER-SIDE on OpenRouter, so they survive gateways like Bifrost that strip the request-body `provider` field. Reference a preset as model `@preset/` (or `openrouter/@preset/` through Bifrost). BYOK provider keys (e.g. wafer) are an account-level Integrations setting, not part of the preset.", + "_comment": "Legacy declarative OpenRouter presets. Sync to the OpenRouter account with `just openrouter-presets-sync` (tools/openrouter_presets.py). Active OpenCode routing now prefers Bifrost model-policy suffixes such as `openrouter/deepseek/deepseek-v4-flash[zdr,provider=digitalocean]`; keep presets only for clients that cannot use suffix models.", "presets": [ { "slug": "zdr-deepseek-wafer", @@ -10,6 +10,18 @@ "allow_fallbacks": false } } + }, + { + "slug": "zdr-deepseek-v4-pro", + "config": { + "model": "deepseek/deepseek-v4-pro", + "provider": { + "zdr": true, + "data_collection": "deny", + "only": ["digitalocean"], + "allow_fallbacks": false + } + } } ] } diff --git a/docker-compose.yml b/docker-compose.yml index f4afb52..78144e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,7 +48,9 @@ services: # Integration endpoints: /openai/v1 (OpenAI-compatible), /anthropic (Claude Code), # /genai (Gemini). Web UI + request logs at http://127.0.0.1:8090. bifrost: - image: maximhq/bifrost:latest + image: ankit/bifrost-dynamic:local + build: + context: /projects/dockers/bifrost-dynamic container_name: bifrost hostname: bifrost restart: unless-stopped diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bfe68bf..e3e2d34 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,43 @@ Garmin / banking / Playnite / AoE4-replay / X-bookmarks pipelines on Dagster (+ DBOS / Restate experiments). Full detail: [`pipelines/docs/CHANGELOG.md`](../pipelines/docs/CHANGELOG.md). +## 2026-06-26 + +### Bifrost Model-Policy Suffix Plugin +- Switched the `bifrost` Compose service to build `/projects/dockers/bifrost-dynamic` as + `ankit/bifrost-dynamic:local`. +- Added `model-policy-suffix` to `config/bifrost.config.json.tmpl` and the live rendered Bifrost + config, loading `/app/plugins/model-policy-suffix.so`. +- Updated OpenCode's default model to + `bifrost/openrouter/deepseek/deepseek-v4-pro[zdr,provider=digitalocean]`. +- Replaced visible OpenCode preset routes with suffix routes, including + `bifrost/openrouter/deepseek/deepseek-v4-flash[zdr,provider=digitalocean]`. +- Removed the DS4 Flash DigitalOcean preset from the declarative preset config so new + model/provider combinations are managed by Bifrost suffixes instead. +- Verified the suffix route without OpenRouter presets: impossible provider pins fail at + OpenRouter, DigitalOcean pins report `provider_name: DigitalOcean`, and OpenCode succeeds through + the suffix model. + +### OpenCode DeepSeek v4 Pro ZDR Preset +- Added `zdr-deepseek-v4-pro` to `config/openrouter-presets.json`, routing + `deepseek/deepseek-v4-pro` through OpenRouter with `provider.zdr:true` and + `provider.data_collection:"deny"`. Updated it to pin `provider.only:["digitalocean"]` with + `allow_fallbacks:false`. +- Updated `~/.config/opencode/opencode.jsonc` to expose + `bifrost/openrouter/@preset/zdr-deepseek-v4-pro` and make it the default model, while keeping + `bifrost/openrouter/deepseek/deepseek-v4-pro` available for non-ZDR opt-out. +- Added OpenRouter preset `zdr-deepseek-v4-flash-digitalocean` and exposed it in OpenCode as + `bifrost/openrouter/@preset/zdr-deepseek-v4-flash-digitalocean`. +- Restricted OpenCode's visible provider set to `enabled_providers:["bifrost"]` without changing the + auth store, and added `~/.config/opencode/plugins/bifrost-passthrough-headers.js` to attach + `x-bf-passthrough-extra-params:true` on Bifrost chat requests. +- Removed the misleading DS4 Flash `model.options.provider` example from OpenCode config after + OpenRouter logs showed that OpenCode did not transmit it as raw provider-routing JSON. +- Synced the preset to OpenRouter and verified it through Bifrost. +- Documented the Bifrost source/discussion finding: stock aliases/routing do not inject OpenRouter + `provider` fields; central Bifrost-owned policy would require `extra_params` per request or a + custom plugin on a dynamically linked Bifrost build. + ## Bifrost LLM gateway (2026-06-20) - Added the `bifrost` compose service (`maximhq/bifrost:latest`) — a Go OpenAI/Anthropic-compatible LLM gateway alongside the existing LiteLLM one. On `mybridge`; other services reach it at diff --git a/docs/NOTES.md b/docs/NOTES.md index b8469c4..d691cc3 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -8,6 +8,89 @@ Production status, Garmin API quirks, auth/rate-limit notes, landing-zone contract, and pending work. Full detail: [`pipelines/docs/NOTES.md`](../pipelines/docs/NOTES.md). +## 2026-06-26 + +### Bifrost Model-Policy Suffix Plugin +#### Decision +- Replaced the "one OpenRouter preset per model/provider" approach with a custom Bifrost image from + `/projects/dockers/bifrost-dynamic`. +- Bifrost now loads `/app/plugins/model-policy-suffix.so`, which parses a trailing OpenRouter model + suffix such as `deepseek/deepseek-v4-flash[zdr,provider=digitalocean]`. +- The plugin strips the suffix before provider routing and injects OpenRouter's upstream + `provider` request-body object via Bifrost `ExtraParams`. +- Active OpenCode default: + `bifrost/openrouter/deepseek/deepseek-v4-pro[zdr,provider=digitalocean]`. +- DS4 Flash DigitalOcean/ZDR example: + `bifrost/openrouter/deepseek/deepseek-v4-flash[zdr,provider=digitalocean]`. +#### Suffix syntax +- `zdr` expands to `provider.zdr:true` and `provider.data_collection:"deny"`. +- `provider=digitalocean` expands to `provider.only:["digitalocean"]` and, by default, + `provider.allow_fallbacks:false`. +- `allow_fallbacks=true|false` overrides that default. +- `order=a|b`, `only=a|b`, `ignore=a|b`, and generic `key=value` are passed into OpenRouter's + `provider` object. +#### Verification +- `/api/plugins` reports `model-policy-suffix` active. +- Negative route test + `openrouter/deepseek/deepseek-v4-flash[provider=definitely-not-a-provider]` returned OpenRouter + 404 `No allowed providers are available`, proving the suffix reached OpenRouter as provider + routing policy. +- Direct Bifrost call + `openrouter/deepseek/deepseek-v4-flash[zdr,provider=digitalocean]` returned + OpenRouter generation `gen-1782518325-zVFyzTnLvtvl58676pmr`; metadata reported + `provider_name: DigitalOcean`, model `deepseek/deepseek-v4-flash-20260423`, and `preset_id:null`. +- OpenCode call + `bifrost/openrouter/deepseek/deepseek-v4-flash[zdr,provider=digitalocean]` returned + `OPENCODE_SUFFIX_DO_OK`; Bifrost logs show the plugin applied the policy and stripped the suffix. + +### OpenCode DeepSeek v4 Pro ZDR Opt-In +#### Status +- Legacy/superseded: OpenRouter presets still exist, but OpenCode now uses Bifrost model-policy + suffixes so new model/provider combinations do not require new OpenRouter presets. +#### Decision +- Added OpenRouter preset `zdr-deepseek-v4-pro` with `model: deepseek/deepseek-v4-pro` and + `provider: {zdr:true, data_collection:"deny", only:["digitalocean"], allow_fallbacks:false}`. + This keeps ZDR selection opt-in by model string instead of turning on OpenRouter account-wide + privacy enforcement, and pins DS4 to DigitalOcean instead of any ZDR provider. +- Previously, OpenCode defaulted to `bifrost/openrouter/@preset/zdr-deepseek-v4-pro`. That has been + replaced by the suffix model above. The regular `bifrost/openrouter/deepseek/deepseek-v4-pro` + model remains listed for explicit opt-out. +- OpenCode is configured with `enabled_providers:["bifrost"]`, so authenticated non-Bifrost + providers remain on disk but are hidden from OpenCode model/provider selection. +- Previously added `zdr-deepseek-v4-flash-digitalocean` for DS4 Flash. Prefer the suffix model + `bifrost/openrouter/deepseek/deepseek-v4-flash[zdr,provider=digitalocean]`; keep + `bifrost/openrouter/deepseek/deepseek-v4-flash` only for default OpenRouter routing. +#### Verification +- `just openrouter-presets-sync` stored the preset live on OpenRouter. +- `just bifrost-test openrouter/@preset/zdr-deepseek-v4-pro` returned `BIFROST_OK`. +- `opencode models` lists only `bifrost/...` models after `enabled_providers:["bifrost"]`. +- OpenRouter generation `gen-1782513184-lflS3t3ad9HW32o4sFYz` reported + `provider_name: DigitalOcean` and the same preset id after a Bifrost call. +- OpenCode call `bifrost/openrouter/@preset/zdr-deepseek-v4-flash-digitalocean` returned + `DS4_FLASH_DO_OK` and Bifrost logged model `@preset/zdr-deepseek-v4-flash-digitalocean`. +- Direct Bifrost call with the same preset returned OpenRouter generation + `gen-1782517414-ACqNHQ3wIkSwQQh0C1YB`; OpenRouter metadata reported + `provider_name: DigitalOcean` and model `deepseek/deepseek-v4-flash-20260423`. +#### Bifrost-native ZDR management +- Source/discussion check: Bifrost aliases and routing rules centralize model/provider resolution, + but the stock config schema does not let an alias inject arbitrary OpenRouter request-body fields + such as `provider.only`, `provider.zdr`, or `provider.data_collection`. +- Bifrost can pass OpenRouter-specific fields with `extra_params`, gated by + `x-bf-passthrough-extra-params: true`; that is per request and requires client support. +- OpenCode's current AI SDK runtime does not pass `model.options.provider` as raw OpenRouter + request-body JSON through a custom OpenAI-compatible provider. A DS4 Flash test configured this way + routed to GMICloud in OpenRouter logs, so the config now treats non-preset DS4 Flash as an explicit + opt-out/default-route model. +- Added OpenCode plugin `~/.config/opencode/plugins/bifrost-passthrough-headers.js` to attach + `x-bf-passthrough-extra-params:true` to Bifrost chat requests. The header alone does not enforce + ZDR; it only permits Bifrost to preserve `extra_params` when a client can send them. +- A true Bifrost-owned policy is possible via a custom plugin (`HTTPTransportPreHook` or + `PreLLMHook`) that injects the OpenRouter `provider` object before the upstream call. Operational + catch: the current `maximhq/bifrost:latest` container is statically linked, and Bifrost's Go plugin + docs require a dynamically linked binary to load `.so` plugins. +- Superseded: OpenCode no longer needs OpenRouter presets for this use case because the dynamic + Bifrost image now owns suffix-based OpenRouter provider policy. + ## 2026-06-20 - Bifrost LLM gateway - **What**: `bifrost` compose service (`maximhq/bifrost`), a second LLM gateway next to LiteLLM. OpenAI-compatible at `http://bifrost:8080` on `mybridge`; local UI/logs `http://127.0.0.1:8090`. From a8e1d0d4c65272ee393b0cc6764c6936159d5b07 Mon Sep 17 00:00:00 2001 From: Devbox Agent Date: Fri, 26 Jun 2026 17:19:21 -0700 Subject: [PATCH 02/13] docs(bifrost): document arbitrary OpenRouter suffix params --- docs/CHANGELOG.md | 5 +++++ docs/NOTES.md | 26 ++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e3e2d34..8f8c6c3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -24,6 +24,11 @@ Garmin / banking / Playnite / AoE4-replay / X-bookmarks pipelines on Dagster - Verified the suffix route without OpenRouter presets: impossible provider pins fail at OpenRouter, DigitalOcean pins report `provider_name: DigitalOcean`, and OpenCode succeeds through the suffix model. +- Expanded the Bifrost suffix plugin to accept arbitrary OpenRouter request params from the model + string via raw JSON object, quoted JSON, `json64:...`, query-style, or dotted-key suffixes while + preserving the shorthand `[zdr,provider=...]` syntax. +- Added a configured OpenCode `json64:` DS4 Flash DigitalOcean example, because OpenCode rejects + arbitrary unlisted model strings even though direct Bifrost calls can use them. ### OpenCode DeepSeek v4 Pro ZDR Preset - Added `zdr-deepseek-v4-pro` to `config/openrouter-presets.json`, routing diff --git a/docs/NOTES.md b/docs/NOTES.md index d691cc3..4f14f8f 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -27,8 +27,20 @@ contract, and pending work. Full detail: - `provider=digitalocean` expands to `provider.only:["digitalocean"]` and, by default, `provider.allow_fallbacks:false`. - `allow_fallbacks=true|false` overrides that default. -- `order=a|b`, `only=a|b`, `ignore=a|b`, and generic `key=value` are passed into OpenRouter's - `provider` object. +- `order=a|b`, `only=a|b`, and `ignore=a|b` are passed into OpenRouter's `provider` object. +- Dotted directives set arbitrary OpenRouter request fields, e.g. + `deepseek/deepseek-v4-flash[provider.only=digitalocean,reasoning.effort=high]`. +- Query-style suffixes work for shell-friendly params, e.g. + `deepseek/deepseek-v4-flash[?provider.only=digitalocean&reasoning.effort=high]`. +- Raw JSON object suffixes are exact arbitrary request-body passthrough, e.g. + `deepseek/deepseek-v4-flash[{"provider":{"only":["digitalocean"],"allow_fallbacks":false},"reasoning":{"effort":"high"}}]`. + JSON is not mutated by the shorthand defaults; include `"allow_fallbacks":false` when the intent is + a hard provider pin. +- Quoted JSON and `json64:...` payloads are accepted for clients or shells that make raw JSON awkward. +- OpenCode requires model ids to be present in its configured model list; arbitrary unlisted suffixes fail + with `ProviderModelNotFoundError`. A configured JSON64 example is available as + `bifrost/openrouter/deepseek/deepseek-v4-flash[json64:eyJwcm92aWRlciI6eyJvbmx5IjpbImRpZ2l0YWxvY2VhbiJdLCJhbGxvd19mYWxsYmFja3MiOmZhbHNlfX0]`. + Clients that call Bifrost directly can send arbitrary suffix strings without OpenCode registration. #### Verification - `/api/plugins` reports `model-policy-suffix` active. - Negative route test @@ -41,6 +53,16 @@ contract, and pending work. Full detail: `provider_name: DigitalOcean`, model `deepseek/deepseek-v4-flash-20260423`, and `preset_id:null`. - OpenCode call `bifrost/openrouter/deepseek/deepseek-v4-flash[zdr,provider=digitalocean]` returned +- JSON suffix negative route test + `openrouter/deepseek/deepseek-v4-flash[{"provider":{"only":["definitely-not-a-provider"],"allow_fallbacks":false}}]` + returned OpenRouter 404 `No allowed providers are available`. +- JSON suffix DigitalOcean route + `openrouter/deepseek/deepseek-v4-flash[{"provider":{"zdr":true,"data_collection":"deny","only":["digitalocean"],"allow_fallbacks":false}}]` + returned `JSON_SUFFIX_DO_OK`; OpenRouter generation `gen-1782519262-Agsv4Zb3PRmoKoh0DiHy` + reported `provider_name: DigitalOcean`, model `deepseek/deepseek-v4-flash-20260423`, and + `preset_id:null`. +- OpenCode run with the configured JSON64 model returned `OPENCODE_JSON64_SUFFIX_OK`; Bifrost logs + show the plugin applied the suffix and stripped the upstream model to `deepseek/deepseek-v4-flash`. `OPENCODE_SUFFIX_DO_OK`; Bifrost logs show the plugin applied the policy and stripped the suffix. ### OpenCode DeepSeek v4 Pro ZDR Opt-In From be3ef91275b322e92bdde68f0d759fc0102ac216 Mon Sep 17 00:00:00 2001 From: Ankit Soni Date: Fri, 26 Jun 2026 23:41:06 -0700 Subject: [PATCH 03/13] feat(bifrost): add Unsloth Studio provider --- config/bifrost.config.json.tmpl | 30 +++++++++++++ config/bifrost.env.tmpl | 1 + docs/CHANGELOG.md | 29 +++++++++++++ docs/NOTES.md | 75 +++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+) diff --git a/config/bifrost.config.json.tmpl b/config/bifrost.config.json.tmpl index c80dfa5..a83ca24 100644 --- a/config/bifrost.config.json.tmpl +++ b/config/bifrost.config.json.tmpl @@ -146,6 +146,36 @@ } }, + "unsloth": { + "keys": [ + { + "name": "unsloth-studio-key-1", + "value": "env.UNSLOTH_STUDIO_API_KEY", + "models": ["default"], + "weight": 1.0 + } + ], + "network_config": { + "base_url": "http://desktop-win:8888", + "default_request_timeout_in_seconds": 600, + "stream_idle_timeout_in_seconds": 300, + "allow_private_network": true + }, + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "list_models": true, + "chat_completion": true, + "chat_completion_stream": true, + "text_completion": true, + "text_completion_stream": true, + "responses": true, + "responses_stream": true, + "embedding": true + } + } + }, + "speaches": { "keys": [ { diff --git a/config/bifrost.env.tmpl b/config/bifrost.env.tmpl index 55ef7e0..1f72949 100644 --- a/config/bifrost.env.tmpl +++ b/config/bifrost.env.tmpl @@ -6,5 +6,6 @@ ANTHROPIC_API_KEY={{ op://clankers/anthropic-key1/password }} OPENAI_API_KEY={{ op://clankers/openai/password }} NVIDIA_API_KEY={{ op://clankers/nvidia-build/password }} DEEPSEEK_API_KEY={{ op://clankers/deepseek/password }} +UNSLOTH_STUDIO_API_KEY={{ op://clankers/llm-windows/password }} # speaches (local Whisper) needs no auth; Bifrost still requires a key entry. SPEACHES_API_KEY=none diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8f8c6c3..52e09c7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,8 +8,37 @@ Garmin / banking / Playnite / AoE4-replay / X-bookmarks pipelines on Dagster (+ DBOS / Restate experiments). Full detail: [`pipelines/docs/CHANGELOG.md`](../pipelines/docs/CHANGELOG.md). +## 2026-06-27 + +### Bifrost Unsloth stream timeout +- Set Unsloth's Bifrost `stream_idle_timeout_in_seconds` to 300 seconds in the config template, + rendered local config, and live provider SQLite row. +- Set opencode's Bifrost provider `timeout` and `chunkTimeout` options to 300000 ms so its client-side + request and chunk caps match the intended 5-minute window. + + ## 2026-06-26 +### Unsloth Studio provider +- Added a Bifrost custom OpenAI-compatible provider `unsloth` pointed at the local Studio host, with + model `unsloth/default` for the active local Studio model. +- Added `UNSLOTH_STUDIO_API_KEY` to the Bifrost env template, sourced from + `op://clankers/llm-windows/password`. +- Added `bifrost/unsloth/default` to OpenCode's local Bifrost model list. +- Updated OpenCode's `unsloth/default` metadata to advertise a 131072-token context window. +- Updated the Windows Unsloth service to `unsloth==2026.6.9` / `unsloth_zoo==2026.6.7` and + llama.cpp `b9821`. +- Updated the Windows `win-models` Unsloth defaults to 131072 context and removed the obsolete + `--simple-policy` llama installer flag so future llama.cpp updates work with current Studio. +- Added `--reasoning-format deepseek` to the Windows Unsloth serve path and a persistent Studio + route shim that converts Gemma 4 `...` output back into OpenAI-style + `reasoning_content` before Bifrost sees it. +- Verified `unsloth/default` through Bifrost with a streaming prompt above the previous 4096-token + limit. +- Verified Gemma 4 thinking remains visible as metadata: direct Studio streams emit + `delta.reasoning_content`, and Bifrost normalizes that to `delta.reasoning`/`reasoning_details` + while keeping `delta.content` clean. + ### Bifrost Model-Policy Suffix Plugin - Switched the `bifrost` Compose service to build `/projects/dockers/bifrost-dynamic` as `ankit/bifrost-dynamic:local`. diff --git a/docs/NOTES.md b/docs/NOTES.md index 4f14f8f..3e40ebb 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -8,8 +8,83 @@ Production status, Garmin API quirks, auth/rate-limit notes, landing-zone contract, and pending work. Full detail: [`pipelines/docs/NOTES.md`](../pipelines/docs/NOTES.md). +## 2026-06-27 + +### Bifrost Unsloth Stream Timeout +#### Discovery +- Unsloth's Bifrost `default_request_timeout_in_seconds` was already 600 seconds in both the rendered + config and live `/api/providers` output. +- The 120-second cap came from Bifrost's streaming idle fallback: + `DefaultStreamIdleTimeout = 120 * time.Second`. Streaming opencode requests can hit that when the + local model takes longer than two minutes before sending the next SSE chunk. +#### Decision +- Keep Unsloth's total request timeout at 600 seconds and set only its provider-level stream idle + timeout to 300 seconds. +- Set opencode's Bifrost provider `timeout` and `chunkTimeout` to 300000 ms as an explicit client-side + match, even though opencode's schema documents 300000 ms as the request-timeout default. +#### Verification +- Updated `volumes/bifrost/config.db` for provider `unsloth` and restarted the `bifrost` container. +- `/api/providers` now reports `stream_idle_timeout_in_seconds: 300` for `unsloth`, and the container + is healthy. + + ## 2026-06-26 +### Unsloth Studio Provider +#### Decision +- Added `unsloth` as a Bifrost custom OpenAI-compatible provider for the local Studio service. The + tracked template uses the short `desktop-win` host; private host suffixes belong in ignored runtime + config, not committed docs. +- Exposed one model id, `unsloth/default`. Unsloth Studio's OpenAI-compatible chat schema documents + `model` as informational and uses the active loaded model, so `default` is the stable Bifrost handle. +- Bifrost uses `UNSLOTH_STUDIO_API_KEY` from `config/bifrost.env.tmpl`, sourced from + `op://clankers/llm-windows/password`; OpenCode exposes it as `bifrost/unsloth/default`. +#### Gotchas +- The Studio API requires `Authorization: Bearer ...`; unauthenticated `GET /v1/models` returns 401. +- If the 1Password value is the Studio login password rather than a long-lived `sk-unsloth-...` API + key, create an API key via Studio's `/api/auth/api-keys` flow and store that key in the same + 1Password field or a dedicated field before rendering Bifrost secrets. +- Studio 2026.6.9 honors `-c 131072`; earlier 2026.6.7 startup accepted the wrapper flags but still + launched `llama-server` with `-c 4096`. +- Studio's `/v1/status` can still report `max_context_length:4096` even when `context_length` is + `131072` and `llama-server` is running with `-c 131072`; use the process command line or a + long-prompt smoke test as the source of truth. +- Studio currently returns SSE for `/v1/chat/completions` even with `stream:false`; Bifrost streaming + works, but non-stream Bifrost calls can fail while parsing the SSE body as JSON. +- Gemma 4 thinking: llama.cpp can separate thoughts with `--reasoning-format deepseek`, but Studio + wraps those deltas as `...` content for its own UI. The Windows `win-models` + launcher now applies an OpenAI route shim that splits Studio's wrapped stream back into + `delta.reasoning_content` before it leaves `/v1/chat/completions`, while keeping ordinary + `delta.content` free of `` tags. +- Bifrost normalizes Unsloth's `delta.reasoning_content` into `delta.reasoning` plus + `delta.reasoning_details`; clients should display those fields if they want visible thinking. +- `opencode run` currently prints only final content in the terminal smoke test. OpenCode's TUI config + has a `display_thinking` keybind, so use the TUI surface when you want to inspect reasoning; the + provider/proxy stream is carrying the data either way. +- Current `just unsloth serve-lan` launch keeps Studio server-side tools enabled (`--enable-tools`) on + a LAN-reachable port. Bearer auth gates access, but any client with the API key can trigger local + Studio tools, including terminal execution. Use `--disable-tools` for a chat-only LAN service. +#### Verification +- `http://desktop-win:8888/` responds with the Unsloth Studio UI from the host. +- The local Studio OpenAPI document is reachable from inside the Bifrost container and shows + OpenAI-compatible `/v1/chat/completions`, `/v1/models`, `/v1/completions`, + `/v1/embeddings`, and `/v1/responses` endpoints behind HTTP Bearer auth. +- Bifrost was recreated with `UNSLOTH_STUDIO_API_KEY` present in the container environment, and + `/api/providers` reports custom provider `unsloth` active. +- Updated Windows Unsloth Studio to `unsloth==2026.6.9` and `unsloth_zoo==2026.6.7`. +- Updated the Windows llama.cpp runtime to GitHub release `b9821`; Studio status reports + `llama_cpp_installed_tag:"b9821"` and `llama_cpp_latest_tag:"b9821"`. +- `desktop-win` is running the active model + `unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL` with `context_length:131072` and `cache_type_kv:q8_0`. +- A Bifrost streaming request to `unsloth/default` with a prompt larger than the old 4096-token + limit returned HTTP 200 and streamed output. +- Direct Studio streaming test for `unsloth/default` returned separate `reasoning_content` and + answer content without `` tags. The same request through Bifrost returned a first chunk with + `delta.reasoning`/`reasoning_details`, then normal `delta.content` chunks. +- The repo-pinned Gemma 4 chat template was compared against the latest public + `google/gemma-4-26B-A4B-it` template; the custom template is still needed because it carries local + tool-call hardening and multimodal aliases not present in the upstream template. + ### Bifrost Model-Policy Suffix Plugin #### Decision - Replaced the "one OpenRouter preset per model/provider" approach with a custom Bifrost image from From d0c8ee92648708e64ded3109c623b503a342c441 Mon Sep 17 00:00:00 2001 From: Ankit Soni Date: Fri, 26 Jun 2026 23:41:06 -0700 Subject: [PATCH 04/13] docs(bifrost): record 1.6.0 upgrade --- docs/CHANGELOG.md | 7 +++++++ docs/NOTES.md | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 52e09c7..1c6d43e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -16,6 +16,13 @@ Garmin / banking / Playnite / AoE4-replay / X-bookmarks pipelines on Dagster - Set opencode's Bifrost provider `timeout` and `chunkTimeout` options to 300000 ms so its client-side request and chunk caps match the intended 5-minute window. +### Bifrost 1.6.0 dynamic image +- Updated `/projects/dockers/bifrost-dynamic` to build from upstream Bifrost transport tag + `transports/v1.6.0`, keeping the local `model-policy-suffix` plugin image published as + `ankit/bifrost-dynamic:local`. +- Verified Docker Hub publishes `maximhq/bifrost:v1.6.0`; `maximhq/bifrost:latest` currently points + to the same amd64/arm64 image manifest. + ## 2026-06-26 diff --git a/docs/NOTES.md b/docs/NOTES.md index 3e40ebb..70fcb86 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -27,6 +27,22 @@ contract, and pending work. Full detail: - `/api/providers` now reports `stream_idle_timeout_in_seconds: 300` for `unsloth`, and the container is healthy. +### Bifrost 1.6.0 Dynamic Image +#### Discovery +- `maximhq/bifrost:1.6.0` is not published, but `maximhq/bifrost:v1.6.0` is available. +- `maximhq/bifrost:latest` currently resolves to the same amd64/arm64 manifest as + `maximhq/bifrost:v1.6.0`. +- Upstream source tags are module-scoped; there is no root `v1.6.0` Git tag. The local HTTP transport + build checks out `transports/v1.6.0`. +#### Decision +- Keep Compose on the local dynamic image so the Go plugin remains available, and update that build + to check out upstream tag `transports/v1.6.0`. +#### Verification +- `go test ./...` passes for `/projects/dockers/bifrost-dynamic/policy-model-suffix`. +- `docker compose build bifrost` rebuilt `ankit/bifrost-dynamic:local` successfully. +- `docker compose up -d bifrost` recreated the running service; `/api/version` returns `v1.6.0`, + `/health` returns OK, and `/api/plugins` reports `model-policy-suffix` active. + ## 2026-06-26 From a034a19d5c87fb4efb226ce3feda3c06e7f62ced Mon Sep 17 00:00:00 2001 From: Ankit Soni Date: Sat, 27 Jun 2026 18:25:13 -0700 Subject: [PATCH 05/13] feat(bifrost): add codex subscription provider via oauth shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register a custom 'codex' provider pointing at the codex-oauth sidecar (http://codex-oauth:10531) so any Bifrost client can use a ChatGPT/Codex subscription without API credits. Adds the codex-oauth compose service, CODEX_PROXY_API_KEY env entry, and codex-oauth-* Just recipes. Also switch the deepseek provider to models:["*"] — verified the wildcard now resolves for custom providers in this Bifrost version. Claude-Session: https://claude.ai/code/session_01FdUsCk9cWWKbTFcGDNEYXB --- Justfile | 30 ++++++++++++++++++ config/bifrost.config.json.tmpl | 29 ++++++++++++++++- config/bifrost.env.tmpl | 2 ++ docker-compose.yml | 31 +++++++++++++++++++ docs/CHANGELOG.md | 23 ++++++++++++++ docs/NOTES.md | 55 +++++++++++++++++++++++++++++++-- 6 files changed, 166 insertions(+), 4 deletions(-) diff --git a/Justfile b/Justfile index 3050e2e..453519a 100644 --- a/Justfile +++ b/Justfile @@ -143,6 +143,36 @@ bifrost-test-pin model="openrouter/deepseek/deepseek-v4-flash" provider="wafer": -d '{"model":"{{model}}","messages":[{"role":"user","content":"Reply with exactly: PIN_OK"}],"max_tokens":16,"extra_params":{"provider":{"only":["{{provider}}"],"allow_fallbacks":false}}}' \ | python3 -c 'import sys,json; d=json.load(sys.stdin); print((d["choices"][0]["message"]["content"].strip()+" | resolved="+str(d.get("model"))) if d.get("choices") else "ERR "+str(d.get("status_code"))+" "+json.dumps(d.get("error",{})))' +# ── codex-oauth (ChatGPT/Codex subscription -> Bifrost `codex` provider) ───── +# One-time auth: log a DEDICATED Codex session into secrets/codex-oauth/auth.json +# (kept separate from the host's ~/.codex so refresh tokens never contend). +# --device-auth prints a code + URL you open on any device — no localhost callback, +# so it works on this headless box without SSH port-forwarding. +codex-oauth-login: + mkdir -p {{SECRETS_DIR}}/codex-oauth + CODEX_HOME="$(pwd)/{{SECRETS_DIR}}/codex-oauth" codex login --device-auth + @echo "Wrote {{SECRETS_DIR}}/codex-oauth/auth.json — now: just up --build codex-oauth" + +# Build + (re)start the proxy, then confirm it can list account models (proves auth). +codex-oauth-up: + {{COMPOSE}} up -d --build codex-oauth + +codex-oauth-logs: + {{COMPOSE}} logs -f codex-oauth + +# Verify the proxy is authed: list the Codex models your subscription exposes. +codex-oauth-models: + curl -fsS --max-time 30 http://127.0.0.1:{{env('CODEX_OAUTH_PORT', '10531')}}/v1/models \ + | python3 -c 'import sys,json; d=json.load(sys.stdin); print("\n".join("- "+m["id"] for m in d.get("data",[]))) or "no models (check auth)"' + +# End-to-end smoke test: call the subscription through Bifrost's `codex` provider. +# Usage: just codex-oauth-test [model] +codex-oauth-test model="codex/gpt-5.5": + curl -fsS --max-time 120 {{BIFROST_URL}}/openai/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{"model":"{{model}}","messages":[{"role":"user","content":"Reply with exactly: CODEX_OK"}],"max_tokens":20}' \ + | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["choices"][0]["message"]["content"].strip() if d.get("choices") else "ERR "+str(d.get("status_code"))+" "+json.dumps(d.get("error",{})))' + # Sync declarative OpenRouter presets (config/openrouter-presets.json) to the # OpenRouter account. Presets bake provider routing server-side, so they survive # Bifrost (which strips the request-body `provider` field) — this is how we pin diff --git a/config/bifrost.config.json.tmpl b/config/bifrost.config.json.tmpl index a83ca24..53c92d1 100644 --- a/config/bifrost.config.json.tmpl +++ b/config/bifrost.config.json.tmpl @@ -95,6 +95,33 @@ ] }, + "codex": { + "keys": [ + { + "name": "codex-oauth-noauth", + "value": "env.CODEX_PROXY_API_KEY", + "models": ["*"], + "weight": 1.0 + } + ], + "network_config": { + "base_url": "http://codex-oauth:10531", + "default_request_timeout_in_seconds": 600, + "stream_idle_timeout_in_seconds": 300, + "allow_private_network": true + }, + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "list_models": true, + "chat_completion": true, + "chat_completion_stream": true, + "responses": true, + "responses_stream": true + } + } + }, + "nvidia": { "keys": [ { @@ -129,7 +156,7 @@ { "name": "deepseek-key-1", "value": "env.DEEPSEEK_API_KEY", - "models": ["deepseek-chat", "deepseek-reasoner"], + "models": ["*"], "weight": 1.0 } ], diff --git a/config/bifrost.env.tmpl b/config/bifrost.env.tmpl index 1f72949..5457e3e 100644 --- a/config/bifrost.env.tmpl +++ b/config/bifrost.env.tmpl @@ -9,3 +9,5 @@ DEEPSEEK_API_KEY={{ op://clankers/deepseek/password }} UNSLOTH_STUDIO_API_KEY={{ op://clankers/llm-windows/password }} # speaches (local Whisper) needs no auth; Bifrost still requires a key entry. SPEACHES_API_KEY=none +# codex-oauth proxy needs no auth (internal, mybridge-only); Bifrost still requires a key entry. +CODEX_PROXY_API_KEY=none diff --git a/docker-compose.yml b/docker-compose.yml index 78144e1..fbafbab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,6 +62,37 @@ services: ports: - "127.0.0.1:${BIFROST_PORT:-8090}:8080" # local debug + Web UI; LAN/TLS via Caddy + # ── codex-oauth: ChatGPT/Codex subscription -> OpenAI-compatible /v1 ───────── + # Lets Bifrost ride a ChatGPT Plus/Pro subscription (no API credits) by wrapping + # EvanZhouDev/openai-oauth: it reads a Codex OAuth auth.json, auto-refreshes the + # token, and proxies to chatgpt.com/backend-api/codex. Bifrost registers it as the + # custom `codex` provider (base_url http://codex-oauth:10531; see bifrost.config.json.tmpl). + # On mybridge so Bifrost reaches it at http://codex-oauth:10531. + # + # AUTH IS A ONE-TIME MANUAL STEP — use a DEDICATED login, not the host's ~/.codex, + # so this proxy's rotating refresh token can't invalidate the host Codex CLI: + # CODEX_HOME="$(pwd)/secrets/codex-oauth" codex login --device-auth + # --device-auth prints a code + URL (no localhost:1455 callback), so it works on this + # headless box without port-forwarding. The auth.json is password-equivalent; secrets/ gitignored. + # ToS: personal single-seat use only — internal (no LAN port), no token pooling. + codex-oauth: + image: ankit/codex-oauth-proxy:local + build: + context: /projects/dockers/codex-oauth-proxy + container_name: codex-oauth + hostname: codex-oauth + restart: unless-stopped + volumes: + - ./secrets/codex-oauth:/codex # dedicated auth.json (rw: proxy refreshes it); gitignored + ports: + - "127.0.0.1:${CODEX_OAUTH_PORT:-10531}:10531" # local debug only; service-to-service via hostname + healthcheck: + test: ["CMD", "nc", "-z", "127.0.0.1", "10531"] # liveness only; avoids hitting chatgpt upstream + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + # ── SillyTavern: LLM chat frontend, pointed at Bifrost ────────────── # Web UI on 8000. On mybridge so it reaches the gateway at http://bifrost:8080. # Off-box access is gated by Caddy (private_only); whitelistMode is disabled in diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1c6d43e..644d019 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,29 @@ Garmin / banking / Playnite / AoE4-replay / X-bookmarks pipelines on Dagster ## 2026-06-27 +### Codex/ChatGPT subscription as Bifrost `codex` provider +- Added `codex-oauth` Compose service (`ankit/codex-oauth-proxy:local`, built from + `/projects/dockers/codex-oauth-proxy/Dockerfile`) wrapping EvanZhouDev/openai-oauth: turns a ChatGPT + Plus/Pro subscription into an OpenAI-compatible `/v1` endpoint on `:10531`, auto-refreshing the + Codex OAuth token and proxying to `chatgpt.com/backend-api/codex`. Internal (mybridge + loopback + debug port); dedicated `auth.json` mounted from `secrets/codex-oauth/` (gitignored). +- Registered the custom provider `codex` in `config/bifrost.config.json.tmpl` + (`base_url http://codex-oauth:10531`, `base_provider_type: openai`, `models: ["*"]`). Added + placeholder `CODEX_PROXY_API_KEY=none` to `config/bifrost.env.tmpl` (shim needs no key; Bifrost + requires a key entry). +- Used `["*"]` rather than an explicit model list: a throwaway-Bifrost test (same image) proved the + wildcard now resolves for custom providers and routes any `codex/` to the shim + (`codex/gpt-5.3-codex-spark` returned a real completion). This contradicts the older NVIDIA-era note + that custom providers can't wildcard — that limitation is fixed in this Bifrost version. The shim's + `/v1/models` is account-aware, so it (not a static list) is the source of truth for what exists. +- Added Justfile recipes: `codex-oauth-login`, `codex-oauth-up`, `codex-oauth-logs`, + `codex-oauth-models`, `codex-oauth-test`. +- Activation is manual (needs `op` + browser OAuth): `just rs` → `just codex-oauth-login` → + `just up --build codex-oauth` → `just up bifrost`. See NOTES for the full runbook. +- Switched the `deepseek` custom provider from the explicit `deepseek-chat`/`deepseek-reasoner` list to + `models: ["*"]` (same now-verified wildcard support). `anthropic`/`openai` were already `["*"]`; + `nvidia` kept explicit (its NIM catalog is a fixed allowlist). + ### Bifrost Unsloth stream timeout - Set Unsloth's Bifrost `stream_idle_timeout_in_seconds` to 300 seconds in the config template, rendered local config, and live provider SQLite row. diff --git a/docs/NOTES.md b/docs/NOTES.md index 70fcb86..7eadcb1 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -10,6 +10,51 @@ contract, and pending work. Full detail: ## 2026-06-27 +### Codex/ChatGPT Subscription Through Bifrost (`codex` provider) +#### Goal +- Let Bifrost serve a ChatGPT Plus/Pro (Codex) subscription so any gateway client (SillyTavern, + opencode, etc.) can use `gpt-5.x` without burning paid API credits. Updates the old "Codex subs + DON'T work via Bifrost" note below — still true that **Bifrost has no built-in subscription auth**. +#### Discovery +- Bifrost upstream is **not** there yet: feature issues + [#2316](https://github.com/maximhq/bifrost/issues/2316) (device-code) and + [#4459](https://github.com/maximhq/bifrost/issues/4459) (auto-detect passthrough) are both OPEN; + PRs #2715/#2775 were closed. PR #4377 ("adds chatgpt passthrough") merged a static + `/chatgpt_passthrough` → `chatgpt.com/backend-api/codex` route, but the maintainer says it's "a bit + different" — it only helps the official Codex CLI (which still holds its own OAuth token), not + arbitrary OpenAI-compatible clients. +- Proven community path (from #2316 comments): run a small OAuth shim that turns the subscription into + an OpenAI-compatible `/v1` endpoint, then register it as a custom Bifrost provider. +#### Decision +- Added a `codex-oauth` Compose service wrapping + [EvanZhouDev/openai-oauth](https://github.com/EvanZhouDev/openai-oauth) (chosen over + icebear0828/codex-proxy: single-purpose, auditable, leans on OpenAI's official `codex login` rather + than reimplementing OAuth). It reads a Codex `auth.json`, auto-refreshes, and proxies to + `chatgpt.com/backend-api/codex`, exposing `/v1/chat/completions` + `/v1/responses` on `:10531`. +- Registered it in Bifrost as the custom provider `codex` (`base_url http://codex-oauth:10531`, + `base_provider_type: openai`, `models: ["*"]`), same shape as `unsloth`/`speaches`/`deepseek`. +- **Wildcard works for custom providers now** (verified on a throwaway Bifrost of the same image): + `["*"]` resolves and routes any `codex/` to the shim — `codex/gpt-5.3-codex-spark` returned a + real completion. This overturns the older NVIDIA-era gotcha ("custom providers can't wildcard"); + no need to hand-maintain a model list. Use `just codex-oauth-models` to see what the account exposes + (currently `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.3-codex-spark`, `codex-auto-review`). +- **Dedicated auth**, not the host `~/.codex`: Codex refresh tokens rotate, so a shared `auth.json` + would have the host CLI and the proxy invalidate each other. The proxy gets its own + `secrets/codex-oauth/auth.json` via a separate `codex login` (`just codex-oauth-login`). +- Internal only (mybridge; loopback debug port `10531`), single seat, no token pooling — staying + inside the personal-use lane of OpenAI's terms. +#### Verification +- `docker compose build codex-oauth` builds `ankit/codex-oauth-proxy:local`; `openai-oauth` v1.0.2 and + `codex` bins present, `--help` OK, busybox `nc` available for the healthcheck. +- Template renders to valid JSON with `codex` among providers; `docker compose config codex-oauth` + parses; running Bifrost config left untouched (still 7 providers, no `codex` yet). +#### Next steps (manual — need `op` + browser) +1. `just rs` (1Password unlocked) to render `secrets/bifrost.{env,config.json}` with the `codex` provider. +2. `just codex-oauth-login` — dedicated `codex login --device-auth` (prints a code + URL; works + headless, no localhost:1455 port-forward needed). +3. `just up --build codex-oauth` then `just up bifrost` (env changed → recreate, not restart). +4. Verify: `just codex-oauth-models`, `just bifrost-providers` (codex active), `just codex-oauth-test`. + ### Bifrost Unsloth Stream Timeout #### Discovery - Unsloth's Bifrost `default_request_timeout_in_seconds` was already 600 seconds in both the rendered @@ -256,11 +301,15 @@ contract, and pending work. Full detail: spent; raised to $15 → ~$5 free). Free models don't draw it, but paid/BYOK calls need headroom for the BYOK fee — if paid calls 403 with "Key limit exceeded (total limit)", raise it at . -- **Claude/Codex subs DON'T work via Bifrost**: it proxies **API keys only**. Claude Code and Codex - CLI both prefer their own subscription OAuth and have to be logged out to point at a gateway — - there's no way to ride a Pro/Max/ChatGPT subscription through Bifrost. The `anthropic`/`openai` +- **Claude/Codex subs DON'T work via Bifrost** (natively): it proxies **API keys only**. Claude Code + and Codex CLI both prefer their own subscription OAuth and have to be logged out to point at a + gateway — Bifrost has no built-in subscription pass-through. The `anthropic`/`openai` providers here are API-key billed (and the `anthropic-key1` key currently has $0 balance, so Claude-via-Bifrost 400s with "credit balance too low" until funded). + **UPDATE 2026-06-27:** worked around for ChatGPT/Codex by adding the `codex-oauth` shim service + + Bifrost custom provider `codex` (see the 2026-06-27 "Codex/ChatGPT Subscription Through Bifrost" + entry above). The same pattern (an OAuth→OpenAI shim as a custom provider) could ride a Claude Max + subscription if wanted. - **Free-model flakiness**: OpenRouter free models are heavily shared/rate-limited — expect 429s on hot ones (llama-3.3-70b, qwen3-coder) and occasional slow ones (bumped the openrouter `default_request_timeout_in_seconds` to 120). `openai/gpt-oss-20b:free` was reliable in testing. From 8d62bc2bb6fa277938c768ad7b82879cf911b064 Mon Sep 17 00:00:00 2001 From: Ankit Soni Date: Sat, 27 Jun 2026 18:25:13 -0700 Subject: [PATCH 06/13] feat(openclaw): route all agents through bifrost as sole provider Add a custom 'bifrost' provider (http://bifrost:8080/openai/v1) and restrict agents.defaults.models to a bifrost/*-only allowlist, so OpenClaw uses Bifrost exclusively. Primary model is bifrost/codex/gpt-5.4 (ChatGPT subscription). Claude-Session: https://claude.ai/code/session_01FdUsCk9cWWKbTFcGDNEYXB --- config/openclaw.config.patch.json.tmpl | 32 ++++++++++++++++++++++++++ docs/CHANGELOG.md | 13 +++++++++++ 2 files changed, 45 insertions(+) diff --git a/config/openclaw.config.patch.json.tmpl b/config/openclaw.config.patch.json.tmpl index 8205ae3..b50b721 100644 --- a/config/openclaw.config.patch.json.tmpl +++ b/config/openclaw.config.patch.json.tmpl @@ -12,6 +12,18 @@ }, "agents": { "defaults": { + "model": { + "primary": "bifrost/codex/gpt-5.4", + "fallbacks": [] + }, + "models": { + "bifrost/openai/gpt-5.4-mini": {}, + "bifrost/codex/gpt-5.5": {}, + "bifrost/codex/gpt-5.4": {}, + "bifrost/deepseek/deepseek-v4-flash": {}, + "bifrost/deepseek/deepseek-v4-pro": {}, + "bifrost/anthropic/claude-opus-4-8": {} + }, "thinkingDefault": "high" }, "list": [ @@ -41,5 +53,25 @@ "order": { "openai": ["openai:{{ op://clankers/codex/username }}", "openai:default"] } + }, + "models": { + "mode": "merge", + "providers": { + "opencode": null, + "bifrost": { + "baseUrl": "http://bifrost:8080/openai/v1", + "apiKey": "none", + "api": "openai-completions", + "contextWindow": 200000, + "models": [ + { "id": "openai/gpt-5.4-mini", "name": "Bifrost · gpt-5.4-mini (OpenAI API key)" }, + { "id": "codex/gpt-5.5", "name": "Bifrost · gpt-5.5 (ChatGPT sub)" }, + { "id": "codex/gpt-5.4", "name": "Bifrost · gpt-5.4 (ChatGPT sub)" }, + { "id": "deepseek/deepseek-v4-flash", "name": "Bifrost · DeepSeek V4 Flash" }, + { "id": "deepseek/deepseek-v4-pro", "name": "Bifrost · DeepSeek V4 Pro" }, + { "id": "anthropic/claude-opus-4-8", "name": "Bifrost · Claude Opus 4.8" } + ] + } + } } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 644d019..d64049d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,19 @@ Garmin / banking / Playnite / AoE4-replay / X-bookmarks pipelines on Dagster ## 2026-06-27 +### OpenClaw routed entirely through Bifrost +- Added a custom `bifrost` provider to `config/openclaw.config.patch.json.tmpl` + (`baseUrl http://bifrost:8080/openai/v1`, `api: openai-completions`, `apiKey: none` — Bifrost has + `enforce_auth_on_inference: false`). Model ids carry Bifrost's `provider/model` prefix, so OpenClaw + refs are `bifrost/codex/gpt-5.4`, `bifrost/deepseek/deepseek-v4-flash`, etc. (Bifrost receives the + canonical `codex/gpt-5.4`). +- Set `agents.defaults.models` to a `bifrost/*`-only allowlist, making Bifrost the **sole** provider + OpenClaw can use. Primary model is `bifrost/codex/gpt-5.4` (ChatGPT subscription, no API credits). +- `fallbacks: []` — note the Codex sub is rate-limited (5h primary window); with no fallback, agents + error when the limit is hit. Candidate fallbacks if wanted: `bifrost/openai/gpt-5.4-mini` or + `bifrost/deepseek/deepseek-v4-flash`. +- Apply: `just rs` → `just restart openclaw` (entrypoint re-applies the patch on start; no env change). + ### Codex/ChatGPT subscription as Bifrost `codex` provider - Added `codex-oauth` Compose service (`ankit/codex-oauth-proxy:local`, built from `/projects/dockers/codex-oauth-proxy/Dockerfile`) wrapping EvanZhouDev/openai-oauth: turns a ChatGPT From dc5d12af7103066f5dcd9e1e7a29c45db68fd982 Mon Sep 17 00:00:00 2001 From: Azimuth Date: Sun, 28 Jun 2026 01:07:41 -0700 Subject: [PATCH 07/13] config(bifrost): increase OpenRouter timeout and add nemotron-3-ultra model - Increase default_request_timeout_in_seconds from 120 to 600 for long /imagine scene prompt-generation tests - Remove hardcoded HTTP-Referer and X-Title headers from network_config - Add nvidia/nemotron-3-ultra-550b-a55b to the model list Azimuth-Run: autosweep_20260628T080300Z_e56f16a3 --- config/bifrost.config.json.tmpl | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/config/bifrost.config.json.tmpl b/config/bifrost.config.json.tmpl index 53c92d1..2659c22 100644 --- a/config/bifrost.config.json.tmpl +++ b/config/bifrost.config.json.tmpl @@ -65,11 +65,7 @@ ], "network_config": { "base_url": "https://openrouter.ai/api", - "default_request_timeout_in_seconds": 120, - "extra_headers": { - "HTTP-Referer": "https://dev.ankitson.com", - "X-Title": "bifrost-devserver" - } + "default_request_timeout_in_seconds": 600 } }, @@ -132,6 +128,7 @@ "nvidia/llama-3.1-nemotron-nano-8b-v1", "nvidia/nvidia-nemotron-nano-9b-v2", "nvidia/llama-3.3-nemotron-super-49b-v1.5", + "nvidia/nemotron-3-ultra-550b-a55b", "mistralai/mistral-small-4-119b-2603" ], "weight": 1.0 From 767ee4d17bdaec11a2b761bd33dd85ed89856ef4 Mon Sep 17 00:00:00 2001 From: Azimuth Date: Sun, 28 Jun 2026 01:09:00 -0700 Subject: [PATCH 08/13] compose(devserver): add sillytavern-bifrost-image and job-search services; pin openclaw versions - Add sillytavern-bifrost-image A1111-compatible adapter service with healthcheck - Add job-search dedicated tracker service with SIGTERM shutdown, Codex auth mount, and healthcheck - Pin OPENCLAW_VERSION and OPENCLAW_CODEX_VERSION build args to 2026.6.10 Azimuth-Run: autosweep_20260628T080300Z_e56f16a3 --- docker-compose.yml | 63 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index fbafbab..6ec8109 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -119,6 +119,29 @@ services: ports: - "127.0.0.1:${SILLYTAVERN_PORT:-8001}:8000" # local debug; LAN/TLS via Caddy + # ── SillyTavern image adapter: A1111-shaped API backed by external image services ─ + # SillyTavern's OpenAI/OpenRouter image providers are hardcoded to their public + # APIs. This adapter uses the configurable Stable Diffusion WebUI source and + # translates /sdapi/v1/txt2img calls into a configured image backend. + sillytavern-bifrost-image: + build: + context: /projects/dockers/sillytavern-bifrost-image-adapter + image: ankit/sillytavern-bifrost-image:local + container_name: sillytavern-bifrost-image + restart: unless-stopped + depends_on: + - bifrost + ports: + - "127.0.0.1:7862:7860" + env_file: + - ./secrets/bifrost.env + - ./secrets/sillytavern-image.env + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:7860/healthz"] + interval: 15s + timeout: 5s + retries: 5 + # ── MCPProxy: shared MCP gateway with upstream OAuth and scoped agent tokens ─ # Host networking is required for OAuth providers that redirect to MCPProxy's # loopback callback listener. The HTTP API still binds only to mybridge's host @@ -171,6 +194,44 @@ services: volumes: - ./logs:/logs + # ── Job Search: dedicated tracker container with direct SQLite shutdown ───── + # Own service/image so Docker sends SIGTERM directly to the Bun server. The app + # handles that signal by stopping its worker, checkpointing WAL, and closing + # state/jobsearch.db before the container exits. + job-search: + build: + context: /projects/job-search + dockerfile: Dockerfile + image: ankit/job-search:local + container_name: job-search + hostname: job-search + user: "1000:1000" + restart: unless-stopped + init: true + stop_grace_period: 90s + env_file: + - /projects/job-search/.env + environment: + - HOST=0.0.0.0 + - PORT=9006 + - RESUME_RENDER_URL=http://app-runner:3002/api/render + volumes: + - /projects/job-search/state:/app/state + - /projects/job-search/profile:/app/profile + - /projects/job-search/profiles:/app/profiles + - /projects/job-search/applications:/app/applications + - /projects/job-search/runtime:/app/runtime + # Codex CLI auth so extract/fit/answers endpoints work in the container. + - /home/ankit/.codex:/home/ankit/.codex + ports: + - "127.0.0.1:9006:9006" + healthcheck: + test: ["CMD", "bun", "-e", "const r=await fetch('http://127.0.0.1:9006/api/stats'); process.exit(r.ok?0:1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + # ── Agent sandbox: devbox-image container that Hermes drives over SSH ────── agent-devbox: image: ankit/devbox:1.4 @@ -284,6 +345,8 @@ services: dockerfile: Dockerfile args: DEVBOX_TAG: "latest" # ankit/devbox:latest (currently aliases 1.5) + OPENCLAW_VERSION: "2026.6.10" + OPENCLAW_CODEX_VERSION: "2026.6.10" image: ankit/openclaw:local container_name: openclaw hostname: openclaw From 0627d234d48e76fa33310bac8a9ee5bebe4bfd08 Mon Sep 17 00:00:00 2001 From: Azimuth Date: Sun, 28 Jun 2026 01:09:29 -0700 Subject: [PATCH 09/13] compose(pipelines): replace NAS mounts with local placeholders for degraded mode - Replace /mnt/synologydrive bind mounts for /landing_zone and /aoe4-replays with local ./volumes/offline-synology/ placeholders - Comment out original NAS bind lines with dated comment for easy restoration when NAS returns - Keeps pipeline-dagster startable while NAS is offline Azimuth-Run: autosweep_20260628T080300Z_e56f16a3 --- docker-compose.pipelines.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docker-compose.pipelines.yml b/docker-compose.pipelines.yml index 60b3c94..af50479 100644 --- a/docker-compose.pipelines.yml +++ b/docker-compose.pipelines.yml @@ -97,8 +97,12 @@ services: - dagster-home:/app/dagster/.dagster_home # File-drop landing zone (clients on other machines mv files in here; # sensors pick them up). See landing_zone/README.md for the contract. - - /mnt/synologydrive/docs-root/projects/data-dropbox:/landing_zone - - /mnt/synologydrive/docs-root/gaming-backups/aoe4-replays:/aoe4-replays + # NAS degraded mode, 2026-06-27: keep the service startable while + # /mnt/synologydrive is offline. Restore these NAS binds when it returns: + # - /mnt/synologydrive/docs-root/projects/data-dropbox:/landing_zone + # - /mnt/synologydrive/docs-root/gaming-backups/aoe4-replays:/aoe4-replays + - ./volumes/offline-synology/data-dropbox:/landing_zone + - ./volumes/offline-synology/aoe4-replays:/aoe4-replays # Lets command-cron jobs `docker exec` into sibling containers # (e.g. birdclaw in app-runner). Grants host container control — consistent # with the devbox/app-runner setup. From d434f239a6402ac5bb1f3c47e08bba7393f3cb17 Mon Sep 17 00:00:00 2001 From: Azimuth Date: Sun, 28 Jun 2026 01:10:01 -0700 Subject: [PATCH 10/13] config(pipelines): add Hacker News import env vars to Dagster template - Add HN_USERNAMES for comma-separated Hacker News usernames - Add HN_FETCH_LIMIT for per-user item fetch limit - Add HN_CLICKHOUSE_TIMEOUT_SECONDS for long-running ClickHouse remote scans Azimuth-Run: autosweep_20260628T080300Z_e56f16a3 --- config/pipeline-dagster.env.tmpl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/pipeline-dagster.env.tmpl b/config/pipeline-dagster.env.tmpl index 1c9590f..f36412b 100644 --- a/config/pipeline-dagster.env.tmpl +++ b/config/pipeline-dagster.env.tmpl @@ -10,3 +10,6 @@ NTFY_URL=https://ntfy.sh NTFY_TOPIC= NTFY_TOKEN= LANDING_ZONE_DIR=/landing_zone +HN_USERNAMES=zaptheimpaler +HN_FETCH_LIMIT=10000 +HN_CLICKHOUSE_TIMEOUT_SECONDS=21600 From f1bc1b4bac4b017f6d1948493748309a8b029420 Mon Sep 17 00:00:00 2001 From: Azimuth Date: Sun, 28 Jun 2026 01:10:29 -0700 Subject: [PATCH 11/13] pipeline(dagster): add Hacker News user items import job - Add hackernews.py with ClickHouse local query + Postgres upsert job - Install clickhouse binary in Dagster Dockerfile for local dataset access - Add hn_user_items table to shared pipeline schema - Add hackernews_user_items_import_job and daily schedule (04:30 UTC) - Add hn-import Just recipe for manual triggering - Add HN_USERNAMES, HN_FETCH_LIMIT, HN_CLICKHOUSE_TIMEOUT_SECONDS env vars - Decode HTML entities in existing and new hn_user_items rows Azimuth-Run: autosweep_20260628T080300Z_e56f16a3 --- pipelines/Justfile | 6 + pipelines/dagster/Dockerfile | 7 + .../src/pipeline_dagster_proj/definitions.py | 9 +- .../src/pipeline_dagster_proj/hackernews.py | 238 ++++++++++++++++++ pipelines/docs/CHANGELOG.md | 15 ++ pipelines/docs/NOTES.md | 22 ++ .../shared/src/pipeline_shared/schema.py | 30 +++ 7 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 pipelines/dagster/src/pipeline_dagster_proj/hackernews.py diff --git a/pipelines/Justfile b/pipelines/Justfile index 8b81575..fc53d47 100644 --- a/pipelines/Justfile +++ b/pipelines/Justfile @@ -127,6 +127,12 @@ lz-gc: @docker exec pipeline-dagster bash -lc \ "cd /app/dagster && uv run dagster job launch -j landing_zone_gc_job -m pipeline_dagster_proj.definitions" | tail -3 +# Import latest stories/comments for HN_USERNAMES from ClickHouse's updating +# hackernews_history dataset into Postgres table hn_user_items. +hn-import: + @docker exec pipeline-dagster bash -lc \ + "cd /app/dagster && uv run dagster job launch -j hackernews_user_items_import_job -m pipeline_dagster_proj.definitions" | tail -3 + # List ready (un-archived) batches + last 20 entries in the batch ledger. lz-status: @echo "=== ready (pre-archive) ===" diff --git a/pipelines/dagster/Dockerfile b/pipelines/dagster/Dockerfile index 82c1178..a84d09e 100644 --- a/pipelines/dagster/Dockerfile +++ b/pipelines/dagster/Dockerfile @@ -17,6 +17,13 @@ RUN curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-29. && rm /tmp/docker.tgz \ && docker --version +# ClickHouse local is used by the Hacker News import job to attach and query +# ClickHouse's public S3-backed `hackernews_history` dataset without running a +# separate ClickHouse server. +RUN curl -fsSL https://clickhouse.com/ | sh \ + && mv clickhouse /usr/local/bin/clickhouse \ + && clickhouse local --version + WORKDIR /app COPY shared /app/shared diff --git a/pipelines/dagster/src/pipeline_dagster_proj/definitions.py b/pipelines/dagster/src/pipeline_dagster_proj/definitions.py index a062545..822d31f 100644 --- a/pipelines/dagster/src/pipeline_dagster_proj/definitions.py +++ b/pipelines/dagster/src/pipeline_dagster_proj/definitions.py @@ -43,6 +43,10 @@ playnite_gameactivity_job, playnite_landing_sensor, ) +from pipeline_dagster_proj.hackernews import ( + hackernews_user_items_import_job, + hackernews_user_items_import_schedule, +) from pipeline_dagster_proj.housekeeping import ( landing_zone_gc_job, landing_zone_gc_schedule, @@ -237,11 +241,12 @@ def birdclaw_bookmarks_import_schedule(context): jobs=[garmin_full_job, garmin_heal_job, bank_login_job, bank_resume_job, playnite_gameactivity_job, landing_zone_gc_job, aoe4_replays_copy_job, birdclaw_sync_bookmarks_job, - birdclaw_bookmarks_import_job], + birdclaw_bookmarks_import_job, hackernews_user_items_import_job], schedules=[garmin_daily_schedule, garmin_heal_schedule, landing_zone_gc_schedule, birdclaw_sync_bookmarks_schedule, - birdclaw_bookmarks_import_schedule], + birdclaw_bookmarks_import_schedule, + hackernews_user_items_import_schedule], sensors=[bank_otp_sensor, notifier_sensor, playnite_landing_sensor, aoe4_replays_landing_sensor], resources={ diff --git a/pipelines/dagster/src/pipeline_dagster_proj/hackernews.py b/pipelines/dagster/src/pipeline_dagster_proj/hackernews.py new file mode 100644 index 0000000..4f99faf --- /dev/null +++ b/pipelines/dagster/src/pipeline_dagster_proj/hackernews.py @@ -0,0 +1,238 @@ +"""Import recent Hacker News items for configured users. + +The source is ClickHouse's attachable `hackernews_history` dataset, backed by +the public S3 disk from ClickHouse/ClickHouse#29693. The op uses +`clickhouse local` so no separate ClickHouse server is required in the pipeline +container. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from html import unescape +from datetime import datetime, timezone +from typing import Any + +import psycopg +from psycopg.types.json import Jsonb +from dagster import DefaultScheduleStatus, RunRequest, RetryPolicy, job, op, schedule + +from pipeline_shared.config import load_settings +from pipeline_shared.schema import ensure_schema + + +CREATE_HN_TABLE_SQL = """ +CREATE TABLE hackernews_history UUID '259cf9f0-0c4f-451d-9029-7de661f9a085' +( + update_time DateTime DEFAULT now(), + id UInt32, + deleted UInt8, + type Enum8('story'=1,'comment'=2,'poll'=3,'pollopt'=4,'job'=5), + by LowCardinality(String), + time DateTime, + text String, + dead UInt8, + parent UInt32, + poll UInt32, + kids Array(UInt32), + url String, + score Int32, + title String, + parts Array(UInt32), + descendants Int32 +) +ENGINE = ReplacingMergeTree(update_time) +ORDER BY id +SETTINGS refresh_parts_interval = 60, + disk = disk( + readonly = true, + type = 's3_plain_rewritable', + endpoint = 'https://clicklake-test-2.s3.eu-central-1.amazonaws.com/', + use_environment_credentials = false + ) +""" + + +SELECT_USER_ITEMS_SQL = """ +SELECT * +FROM +( + SELECT + update_time, id, deleted, type, `by`, time, text, dead, parent, poll, + kids, url, score, title, parts, descendants + FROM hackernews_history + WHERE `by` = {username:String} + AND toString(type) IN ('story', 'comment') + ORDER BY id, update_time DESC + LIMIT 1 BY id +) +ORDER BY time DESC +LIMIT {limit:UInt32} +FORMAT JSONEachRow +""" + + +UPSERT_HN_ITEM_SQL = """ +INSERT INTO hn_user_items ( + id, author, item_type, hn_time, update_time, deleted, dead, parent, poll, + kids, url, score, title, text, parts, descendants, raw +) VALUES ( + %(id)s, %(author)s, %(item_type)s, %(hn_time)s, %(update_time)s, + %(deleted)s, %(dead)s, %(parent)s, %(poll)s, %(kids)s, %(url)s, %(score)s, + %(title)s, %(text)s, %(parts)s, %(descendants)s, %(raw)s +) +ON CONFLICT (id) DO UPDATE SET + author = EXCLUDED.author, + item_type = EXCLUDED.item_type, + hn_time = EXCLUDED.hn_time, + update_time = EXCLUDED.update_time, + deleted = EXCLUDED.deleted, + dead = EXCLUDED.dead, + parent = EXCLUDED.parent, + poll = EXCLUDED.poll, + kids = EXCLUDED.kids, + url = EXCLUDED.url, + score = EXCLUDED.score, + title = EXCLUDED.title, + text = EXCLUDED.text, + parts = EXCLUDED.parts, + descendants = EXCLUDED.descendants, + raw = EXCLUDED.raw, + imported_at = NOW() +""" + + +def _configured_usernames() -> list[str]: + raw = os.environ.get("HN_USERNAMES", "user") + return [u.strip() for u in raw.split(",") if u.strip()] + + +def _configured_fetch_limit() -> int: + return int(os.environ.get("HN_FETCH_LIMIT", "1000")) + + +def _configured_clickhouse_timeout() -> int: + return int(os.environ.get("HN_CLICKHOUSE_TIMEOUT_SECONDS", "21600")) + + +def _clickhouse_bin() -> str: + return os.environ.get("CLICKHOUSE_BIN", "clickhouse") + + +def _parse_clickhouse_datetime(value: str | None) -> datetime | None: + if not value: + return None + # ClickHouse DateTime values are UTC seconds rendered without an offset in + # JSONEachRow, e.g. "2026-06-20 00:53:24". + return datetime.fromisoformat(value.replace(" ", "T")).replace(tzinfo=timezone.utc) + + +def _run_clickhouse_query(username: str, limit: int) -> list[dict[str, Any]]: + sql = f"{CREATE_HN_TABLE_SQL};\n{SELECT_USER_ITEMS_SQL}" + proc = subprocess.run( + [ + _clickhouse_bin(), + "local", + "--multiquery", + f"--param_username={username}", + f"--param_limit={limit}", + "--query", + sql, + ], + capture_output=True, + text=True, + timeout=_configured_clickhouse_timeout(), + ) + if proc.returncode != 0: + raise RuntimeError( + f"clickhouse local exited {proc.returncode}: {proc.stderr.strip()[-4000:]}" + ) + return [json.loads(line) for line in proc.stdout.splitlines() if line.strip()] + + +def _decode_html_entities(value: Any) -> Any: + if isinstance(value, str): + return unescape(value) + if isinstance(value, list): + return [_decode_html_entities(item) for item in value] + if isinstance(value, dict): + return {key: _decode_html_entities(item) for key, item in value.items()} + return value + + +def _row_for_upsert(row: dict[str, Any]) -> dict[str, Any]: + row = _decode_html_entities(row) + return { + "id": int(row["id"]), + "author": row.get("by") or "", + "item_type": row.get("type") or "", + "hn_time": _parse_clickhouse_datetime(row.get("time")), + "update_time": _parse_clickhouse_datetime(row.get("update_time")), + "deleted": bool(row.get("deleted", 0)), + "dead": bool(row.get("dead", 0)), + "parent": int(row["parent"]) if row.get("parent") else None, + "poll": int(row["poll"]) if row.get("poll") else None, + "kids": [int(v) for v in row.get("kids", [])], + "url": row.get("url") or None, + "score": int(row["score"]) if row.get("score") is not None else None, + "title": row.get("title") or None, + "text": row.get("text") or None, + "parts": [int(v) for v in row.get("parts", [])], + "descendants": ( + int(row["descendants"]) if row.get("descendants") is not None else None + ), + "raw": Jsonb(row), + } + + +@op(retry_policy=RetryPolicy(max_retries=2, delay=120)) +def import_hackernews_user_items_op(context) -> dict[str, int]: + settings = load_settings() + ensure_schema(settings.database_url) + + usernames = _configured_usernames() + fetch_limit = _configured_fetch_limit() + if not usernames: + raise RuntimeError("HN_USERNAMES did not contain any usernames") + + total_read = 0 + total_upserted = 0 + by_user: dict[str, int] = {} + + with psycopg.connect(settings.database_url, autocommit=False) as conn: + with conn.cursor() as cur: + for username in usernames: + context.log.info( + "querying ClickHouse Hacker News dataset: user=%s limit=%d", + username, + fetch_limit, + ) + rows = _run_clickhouse_query(username, fetch_limit) + upsert_rows = [_row_for_upsert(row) for row in rows] + total_read += len(rows) + by_user[username] = len(rows) + if upsert_rows: + cur.executemany(UPSERT_HN_ITEM_SQL, upsert_rows) + total_upserted += len(upsert_rows) + conn.commit() + + context.log.info("Hacker News import rows by user: %s", by_user) + return {"read": total_read, "upserted": total_upserted, **by_user} + + +@job +def hackernews_user_items_import_job(): + import_hackernews_user_items_op() + + +@schedule( + cron_schedule="30 4 * * *", + job=hackernews_user_items_import_job, + default_status=DefaultScheduleStatus.RUNNING, +) +def hackernews_user_items_import_schedule(context): + return RunRequest( + run_key=f"hackernews-user-items-{context.scheduled_execution_time:%Y%m%d}" + ) diff --git a/pipelines/docs/CHANGELOG.md b/pipelines/docs/CHANGELOG.md index ad1365a..0f2ea99 100644 --- a/pipelines/docs/CHANGELOG.md +++ b/pipelines/docs/CHANGELOG.md @@ -1,5 +1,20 @@ # Devserver Changelog +## 2026-06-25 — Hacker News user import + +- Added `hackernews_user_items_import_job`, which queries ClickHouse's + S3-backed `hackernews_history` dataset through `clickhouse local` and upserts + the latest stories/comments for `HN_USERNAMES` into Postgres. +- Added the `hn_user_items` table and indexes to the shared pipeline schema. +- Added `hackernews_user_items_import_schedule`, the `just pipelines hn-import` + manual trigger, and Dagster env template knobs `HN_USERNAMES`, + `HN_FETCH_LIMIT`, and `HN_CLICKHOUSE_TIMEOUT_SECONDS`. +- Installed the ClickHouse binary in the Dagster image for local dataset + attachment without a separate ClickHouse server. +- Changed `hackernews_user_items_import_schedule` to run daily at 04:30 UTC + with a RUNNING default status. +- Decoded HTML entities in existing `hn_user_items` rows. + ## 2026-06-10 — Fix birdclaw bookmarks import WAL sidecar access `birdclaw_bookmarks_import_job` was failing hourly with diff --git a/pipelines/docs/NOTES.md b/pipelines/docs/NOTES.md index 703f68d..cc965eb 100644 --- a/pipelines/docs/NOTES.md +++ b/pipelines/docs/NOTES.md @@ -1,5 +1,27 @@ # Open Threads / Gotchas +## 2026-06-25 — Hacker News user item import + +### Goal +Add a Dagster job that pulls recent stories/comments for configured Hacker +News users from ClickHouse's updating `hackernews_history` dataset and stores +them in the pipeline Postgres DB. + +### Decision +The job uses `clickhouse local` inside the Dagster container to attach the +public S3-backed dataset by UUID, so it does not need a separate ClickHouse +server. It runs the author-filtered ClickHouse query directly and upserts the +latest ReplacingMergeTree version for each returned item. +`HN_USERNAMES` is a comma-separated list of HN handles, `HN_FETCH_LIMIT` caps +how many latest items per user are stored each run, and +`HN_CLICKHOUSE_TIMEOUT_SECONDS` defaults to 21600 because the remote scan can +take a long time. + +### Operations +The schedule runs daily at 04:30 UTC. Run manually with +`just pipelines hn-import`. Set the desired comma-separated `HN_USERNAMES` in +the rendered Dagster env file before relying on the schedule. + ## Production status - **Dagster pipeline** is the active one. `garmin_daily_schedule` is RUNNING diff --git a/pipelines/shared/src/pipeline_shared/schema.py b/pipelines/shared/src/pipeline_shared/schema.py index 414a978..7f9857a 100644 --- a/pipelines/shared/src/pipeline_shared/schema.py +++ b/pipelines/shared/src/pipeline_shared/schema.py @@ -11,6 +11,8 @@ - `anomaly_events` — detected anomalies per (date, metric, kind). - `transactions` — banking transactions (mock bank). - `bank_imports` — bank import workflow audit log. +- `hn_user_items` — selected Hacker News users' latest stories/comments from + ClickHouse's public updating HN dataset. Everything is created with `IF NOT EXISTS`. Idempotent. """ @@ -195,6 +197,34 @@ ON x_bookmarks (tweet_created_at DESC); CREATE INDEX IF NOT EXISTS x_bookmarks_account_created ON x_bookmarks (account_id, tweet_created_at DESC); + +-- Hacker News stories/comments imported from ClickHouse's updating +-- hackernews_history dataset. Keyed by HN item id; re-running the Dagster job +-- upserts the latest ReplacingMergeTree version for each item. +CREATE TABLE IF NOT EXISTS hn_user_items ( + id BIGINT PRIMARY KEY, + author TEXT NOT NULL, + item_type TEXT NOT NULL, + hn_time TIMESTAMPTZ, + update_time TIMESTAMPTZ, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + dead BOOLEAN NOT NULL DEFAULT FALSE, + parent BIGINT, + poll BIGINT, + kids BIGINT[] NOT NULL DEFAULT '{}', + url TEXT, + score INTEGER, + title TEXT, + text TEXT, + parts BIGINT[] NOT NULL DEFAULT '{}', + descendants INTEGER, + raw JSONB NOT NULL, + imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS hn_user_items_author_time + ON hn_user_items (author, hn_time DESC); +CREATE INDEX IF NOT EXISTS hn_user_items_type_time + ON hn_user_items (item_type, hn_time DESC); """ From 675bc7a43474fb8c8c9113bfc28b0f4382672dc3 Mon Sep 17 00:00:00 2001 From: Azimuth Date: Sun, 28 Jun 2026 01:10:52 -0700 Subject: [PATCH 12/13] docs(devserver): record SillyTavern image adapter, job-search, and OpenClaw updates - Document SillyTavern A1111/ComfyUI image generation adapter and Compose service - Document job-search dedicated tracker service with Bun server and SQLite shutdown - Document OpenClaw version pins and Codex auth seeding - Document pipeline-dagster NAS degraded-mode mount replacement - Document LLM API matrix probe script and Unsloth Studio tools update - Update NOTES with privacy cleanup for image adapter relocation Azimuth-Run: autosweep_20260628T080300Z_e56f16a3 --- docs/CHANGELOG.md | 82 ++++++++++++++++++++++ docs/NOTES.md | 171 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 244 insertions(+), 9 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d64049d..fbd9996 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -46,6 +46,30 @@ Garmin / banking / Playnite / AoE4-replay / X-bookmarks pipelines on Dagster `models: ["*"]` (same now-verified wildcard support). `anthropic`/`openai` were already `["*"]`; `nvidia` kept explicit (its NIM catalog is a fixed allowlist). +### OpenClaw default model and image upgrade +- Set the OpenClaw startup config patch to force `openai/gpt-5.4-mini` as the default model with no + fallback chain. +- Removed the stale OpenCode provider override and OpenCode model entries that exposed + `opencode/mimo-v2.5-free` in the default-path model set. +- Pinned the OpenClaw Compose build args to `OPENCLAW_VERSION=2026.6.10` and + `OPENCLAW_CODEX_VERSION=2026.6.10`. +- Rebuilt and restarted the live `ankit/openclaw:local` image; the running container reports + `openclaw@2026.6.10` and `@openclaw/codex@2026.6.10`. +- Seeded missing isolated Codex auth homes for the `main` and `austin` OpenClaw agents from the host + Codex auth file. + +### Job Search dedicated service +- Added a dedicated `job-search` Compose service built from `/projects/job-search` and tagged `ankit/job-search:local`. +- Mounted the live job-search mutable directories into the container and mounted `~/.codex` for extract/fit/answers. +- Pinned `agent-browser@0.27.1` in the image for hostile-page enrichment fallback support. +- Added a 90-second stop grace period and healthcheck for `/api/stats`. +- Built and started the service; a controlled restart exercised the app's SIGTERM shutdown path. + +### Pipeline Dagster NAS degraded-mode mounts +- Temporarily replaced the `pipeline-dagster` `/mnt/synologydrive` bind sources for `/landing_zone` and `/aoe4-replays` with empty local placeholders under `./volumes/offline-synology/`. +- Left the original NAS bind lines commented in `docker-compose.pipelines.yml` so the change can be reverted when the NAS returns. +- Recreated `pipeline-dagster`; it is healthy with degraded empty landing directories. + ### Bifrost Unsloth stream timeout - Set Unsloth's Bifrost `stream_idle_timeout_in_seconds` to 300 seconds in the config template, rendered local config, and live provider SQLite row. @@ -59,6 +83,19 @@ Garmin / banking / Playnite / AoE4-replay / X-bookmarks pipelines on Dagster - Verified Docker Hub publishes `maximhq/bifrost:v1.6.0`; `maximhq/bifrost:latest` currently points to the same amd64/arm64 image manifest. +### LLM API Matrix Probe +- Added `scripts/llm_api_matrix.py` to compare Studio, Bifrost, and optional direct llama-server + behavior across Chat Completions, Responses, streaming, and non-streaming requests. +- Added `just llm-api-matrix` for repeatable local probes. +- The script detects SSE returned to a `stream:false` request, summarizes reasoning vs answer deltas, + and handles long-lived streams without crashing on client read timeout. + +### Unsloth Studio tools +- Updated the Windows `win-models` launcher so Unsloth Studio defaults to `--disable-tools` for the + Bifrost/OpenCode model-server path. +- Kept explicit opt-in available by forwarding extra Just recipe args, e.g. `--enable-tools` for + direct Studio UI sessions. +- Restarted the live Windows Studio service with server-side tools disabled. ## 2026-06-26 @@ -300,3 +337,48 @@ Recipes: `just oc-build` / `oc-up` / `oc-logs` / `ab-logs`. Caddy routes: ### SillyTavern Chat Completion presets - Added a `just sillytavern-preset-copy` recipe to copy local Chat Completion preset JSON files into SillyTavern's `OpenAI Settings` user-volume directory. + +### SillyTavern image generation +- Added an A1111-compatible image adapter that forwards + SillyTavern image requests to either Bifrost's OpenAI-compatible image-generation endpoint or + OpenRouter's image chat-completions endpoint. +- Added the `sillytavern-bifrost-image` Compose service and Just recipes for starting/logging it. +- Updated the live SillyTavern image-generation settings to use the adapter as the Stable Diffusion + WebUI source. +- Switched the adapter backend through ignored runtime env and verified a real image generation. + +## 2026-06-22 + +### SillyTavern ComfyUI image backend +- Added a ComfyUI backend to the image adapter, including checkpoint/sampler/scheduler discovery and + a default txt2img workflow. +- Reconfigured `sillytavern-bifrost-image` to use an external ComfyUI API configured by ignored env. +- Added an adapter smoke test and the + `just sillytavern-image-adapter-test` recipe. +- Updated the live SillyTavern image settings to use a ComfyUI model through the existing Stable + Diffusion WebUI source. +- Disabled SillyTavern OpenAI media inlining in the live user settings so `/imagine me` prompt + generation does not send prior generated image attachments to a text-only DeepSeek/OpenRouter + model. +- Added A1111-compatible no-op responses for `sd-vae`, `sd-modules`, and `latent-upscale-modes` in + the image adapter. +- Increased Bifrost's OpenRouter request timeout from 120 to 600 seconds in the config template, + rendered config, and live provider sqlite row for long `/imagine scene` prompt-generation tests. +- Fixed the image adapter's A1111 model switching by persisting POSTed `/sdapi/v1/options` + `sd_model_checkpoint` values and using the active checkpoint for `/txt2img` requests. +- Moved the adapter's concrete runtime model values out of Compose and into ignored env/config, and + extended the smoke test to assert model switching sticks before generating an image. + +### SillyTavern ComfyUI workflows +- Replaced the active SillyTavern ComfyUI workflow with the latest external copy. +- Added a fixed-parameter ComfyUI workflow and set it as the active SillyTavern workflow. +- Backed up the previous workflow and settings files under + `volumes/sillytavern/data/default-user/backups/`. +- Verified the fixed workflow through the external ComfyUI API and saved a runtime smoke artifact. + +### SillyTavern image adapter relocation +- Moved the adapter implementation out of devserver and into `/projects/dockers`. +- Updated the Compose build context and Just smoke-test recipe to use the external adapter path. +- Removed concrete image backend defaults from the adapter source and Dockerfile; runtime values now + come from ignored env files or live app settings. +- Added a log ignore rule so generated smoke artifacts are not accidentally staged. diff --git a/docs/NOTES.md b/docs/NOTES.md index 7eadcb1..af4f6d2 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -55,6 +55,62 @@ contract, and pending work. Full detail: 3. `just up --build codex-oauth` then `just up bifrost` (env changed → recreate, not restart). 4. Verify: `just codex-oauth-models`, `just bifrost-providers` (codex active), `just codex-oauth-test`. +### OpenClaw Default Model And 2026.6.10 Upgrade +#### Discovery +- The tracked startup patch did not set `agents.defaults.model`, so persisted OpenClaw state controlled + the default model across restarts. +- The Mimo entry came from the custom `models.providers.opencode` catalog override in + `volumes/openclaw/openclaw.json`; it was an OpenCode Zen model entry, not the OpenAI/Codex runtime. +- The OpenClaw service image was still running `openclaw@2026.6.1`; Compose did not pin + `OPENCLAW_VERSION`, so plain rebuilds depended on the Dockerfile default or explicit Just args. +#### Decision +- Declare `openai/gpt-5.4-mini` as the OpenClaw default model in the rendered startup patch. +- Remove the stale OpenCode provider override and OpenCode fallback entries from active config so Mimo + is not offered as an allowed default-path model. +- Pin the OpenClaw Compose build args to `OPENCLAW_VERSION=2026.6.10` and + `OPENCLAW_CODEX_VERSION=2026.6.10`. +- Seed missing isolated per-agent Codex homes from the host Codex auth file for `main` and `austin`; + `gilfoyle` already had one. +#### Verification +- `just upgrade-openclaw 2026.6.10` rebuilt `ankit/openclaw:local` and recreated the running service. +- `openclaw --version` and global npm packages report `openclaw@2026.6.10` and + `@openclaw/codex@2026.6.10`. +- `openclaw models status --json` reports default/resolved default `openai/gpt-5.4-mini`, + no fallbacks, and no `opencode/mimo-v2.5-free` allowed model. +- Gateway logs show `agent model: openai/gpt-5.4-mini` and `gateway ready`. +#### Follow-ups +- OpenClaw doctor still reports stale `exa` and `deepseek` plugin config references. +- A post-restart cron run hit the Codex subscription usage limit for `openai/gpt-5.4-mini`; the default + is correct, but the subscription is currently rate-limited. + +### Job Search Dedicated Docker Service +#### Discovery +- The job-search app was previously a child process of homeserver `app-runner`, so Docker stop/restart signals reached the runner rather than the Bun server directly. +- That shape was a poor fit for SQLite WAL durability during host upgrades/reboots because the child process did not own a clear Docker lifecycle. +#### Decision +- Add a dedicated devserver Compose service `job-search` built from `/projects/job-search/Dockerfile` as image `ankit/job-search:local`. +- Persist only the mutable repo directories as bind mounts: `state`, `profile`, `profiles`, `applications`, and `runtime`. +- Pin `agent-browser@0.27.1` in the image so the hostile-page enrichment fallback still works. +- Give the service `init: true`, `restart: unless-stopped`, and `stop_grace_period: 90s` so the app can checkpoint SQLite on stop. +#### Verification +- `docker compose -f docker-compose.yml build job-search` built the image. +- `docker compose -f docker-compose.yml up -d --no-deps job-search` started the container and healthcheck. +- A controlled `docker compose -f docker-compose.yml restart job-search` logged the app SIGTERM shutdown path before restart. +- `docker exec job-search agent-browser --version` reports `agent-browser 0.27.1`. + +### Pipeline Dagster NAS degraded-mode mounts +#### Discovery +- After the Pop!_OS 24.04 upgrade, `pipeline-dagster` could not recreate while `/mnt/synologydrive` was offline. +- Docker failed before process start while creating bind source paths for `/landing_zone` and `/aoe4-replays`. +#### Decision +- Temporarily replace the two NAS bind sources in `docker-compose.pipelines.yml` with empty local placeholders under `./volumes/offline-synology/`. +- Leave the original NAS bind lines commented in the compose file for rollback. +#### Verification +- `docker compose -f docker-compose.pipelines.yml up -d pipeline-dagster` recreated the service. +- `pipeline-dagster` reached healthy state with the empty placeholder landing directories. +#### Rollback +- When the NAS is back, restore the commented `/mnt/synologydrive/...` binds and remove the `./volumes/offline-synology/...` binds. + ### Bifrost Unsloth Stream Timeout #### Discovery - Unsloth's Bifrost `default_request_timeout_in_seconds` was already 600 seconds in both the rendered @@ -88,6 +144,38 @@ contract, and pending work. Full detail: - `docker compose up -d bifrost` recreated the running service; `/api/version` returns `v1.6.0`, `/health` returns OK, and `/api/plugins` reports `model-policy-suffix` active. +### LLM API Matrix Probe +#### Decision +- Added `scripts/llm_api_matrix.py` as a dependency-free probe for comparing local LLM frontdoors + across Chat Completions vs Responses and `stream:false` vs `stream:true`. +- Added `just llm-api-matrix` as the wrapper recipe. +#### Usage +- Default targets are Studio and Bifrost; direct llama-server is included when `LLAMA_BASE_URL` or + `--llama-url` is set. +- Useful examples: + - `just llm-api-matrix --target bifrost --api responses --stream false` + - `just llm-api-matrix --target bifrost --api responses --stream true --show-samples` + - `just llm-api-matrix --target llama --llama-url http://desktop-win:50149 --api chat --stream both` +#### Verification +- The probe reproduces the current Bifrost/Studio non-stream Responses failure as HTTP 500. +- The probe confirms Bifrost streaming Responses returns SSE events including + `response.reasoning_text.delta`. + +### Unsloth Studio Tools +#### Decision +- Treat Unsloth Studio as a model server behind Bifrost/OpenCode, not as a nested agent runtime. +- Updated the Windows `win-models` launcher so `win-models unsloth serve` defaults to + `--disable-tools`. The existing Just recipes pass through extra args, so direct Studio UI sessions + can still opt in explicitly with `--enable-tools`. +#### Reason +- OpenCode is already the visible tool harness. Leaving Studio server-side tools enabled created a + hidden second loop (`OpenCode -> Bifrost -> Studio -> terminal`) where Studio executed terminal + calls with `session_id=None`; OpenCode could not display or audit those calls and Bifrost timed out + while waiting for stream data. +#### Verification +- Restarted the Windows Studio service; the active process command line includes `--disable-tools`. +- The new Studio log reports server-side tools disabled, and no `execute_tool` entries appeared after + restart. ## 2026-06-26 @@ -122,9 +210,9 @@ contract, and pending work. Full detail: - `opencode run` currently prints only final content in the terminal smoke test. OpenCode's TUI config has a `display_thinking` keybind, so use the TUI surface when you want to inspect reasoning; the provider/proxy stream is carrying the data either way. -- Current `just unsloth serve-lan` launch keeps Studio server-side tools enabled (`--enable-tools`) on - a LAN-reachable port. Bearer auth gates access, but any client with the API key can trigger local - Studio tools, including terminal execution. Use `--disable-tools` for a chat-only LAN service. +- Current `just unsloth serve-lan` launch keeps Studio server-side tools disabled by default on the + LAN-reachable port. Only pass `--enable-tools` for direct Studio UI sessions where Studio should be + the agent runtime. #### Verification - `http://desktop-win:8888/` responds with the Unsloth Studio UI from the host. - The local Studio OpenAPI document is reachable from inside the Bifrost container and shows @@ -177,6 +265,15 @@ contract, and pending work. Full detail: with `ProviderModelNotFoundError`. A configured JSON64 example is available as `bifrost/openrouter/deepseek/deepseek-v4-flash[json64:eyJwcm92aWRlciI6eyJvbmx5IjpbImRpZ2l0YWxvY2VhbiJdLCJhbGxvd19mYWxsYmFja3MiOmZhbHNlfX0]`. Clients that call Bifrost directly can send arbitrary suffix strings without OpenCode registration. +#### Deferred syntax decision +- Consider making URI query syntax the primary advanced format because it is familiar and + percent-encoding gives a standard escape path, e.g. + `deepseek/deepseek-v4-flash[?provider.only=digitalocean&provider.allow_fallbacks=false]`. +- Keep the query inside the bracket suffix rather than turning the whole model id into + `model?key=value`; the bracket keeps the policy layer explicit and avoids surprising clients, + model registries, and logs that treat model ids as opaque strings. +- Keep raw JSON/`json64:` as escape hatches for nested objects and arrays that are awkward in query + strings. Revisit this before promoting the suffix format beyond local Bifrost/OpenCode usage. #### Verification - `/api/plugins` reports `model-policy-suffix` active. - Negative route test @@ -189,6 +286,7 @@ contract, and pending work. Full detail: `provider_name: DigitalOcean`, model `deepseek/deepseek-v4-flash-20260423`, and `preset_id:null`. - OpenCode call `bifrost/openrouter/deepseek/deepseek-v4-flash[zdr,provider=digitalocean]` returned + `OPENCODE_SUFFIX_DO_OK`; Bifrost logs show the plugin applied the policy and stripped the suffix. - JSON suffix negative route test `openrouter/deepseek/deepseek-v4-flash[{"provider":{"only":["definitely-not-a-provider"],"allow_fallbacks":false}}]` returned OpenRouter 404 `No allowed providers are available`. @@ -199,7 +297,6 @@ contract, and pending work. Full detail: `preset_id:null`. - OpenCode run with the configured JSON64 model returned `OPENCODE_JSON64_SUFFIX_OK`; Bifrost logs show the plugin applied the suffix and stripped the upstream model to `deepseek/deepseek-v4-flash`. - `OPENCODE_SUFFIX_DO_OK`; Bifrost logs show the plugin applied the policy and stripped the suffix. ### OpenCode DeepSeek v4 Pro ZDR Opt-In #### Status @@ -387,7 +484,8 @@ contract, and pending work. Full detail: - **Reverse-proxy posture** set declaratively via compose env (`SILLYTAVERN_LISTEN=true`, `WHITELISTMODE=false`, `SECURITYOVERRIDE=true`) so it works on a fresh volume — `config.yaml` itself is in the gitignored volume. ST has no auth of its own; Caddy private_only is the gate. -- **Pointed at Bifrost**: pre-seeded `data/default-user/settings.json` → +- **Pointed at Bifrost**: pre-seeded `data/default-user/settings.json` → `main_api=openai` (stock + default is `koboldhorde`/AI Horde — this is what makes Bifrost the *default* backend), `oai_settings.chat_completion_source=custom`, `custom_url=http://bifrost:8080/openai/v1`, `custom_model=nvidia/meta/llama-3.1-8b-instruct` (+ placeholder `api_key_custom` in secrets.json; Bifrost is keyless). ST proxies the API call server-side, so the internal `bifrost:8080` hostname @@ -397,10 +495,8 @@ contract, and pending work. Full detail: `openrouter/...`, `nvidia/...`, `deepseek/deepseek-chat`). ### Follow-ups (2026-06-20): Caddy, DeepSeek-direct, fastmail -- **Caddy**: Web UI + API now at **https://bifrost.dev.ankitson.com** (private_only / LAN+Tailscale). - Route added to `homeserver:volumes/caddy/dev.Caddyfile` (`reverse_proxy bifrost:8080`; Caddy is on - mybridge so it resolves the container by name) and reloaded live. **That edit is in the homeserver - repo and is currently uncommitted there.** +- **Caddy**: Web UI + API were added behind the private reverse-proxy path. The concrete route lives + in the homeserver repo and should stay out of devserver notes. - **DeepSeek can't be BYOK'd through OpenRouter** — OpenRouter has **no DeepSeek-direct endpoint** for any deepseek slug (`only:["deepseek"]` 404s with `available_providers: [streamlake, deepinfra, novita]`; all the deepseek models on OR are served by third parties). Mistral BYOK *does* work (verified direct: @@ -591,3 +687,60 @@ contract, and pending work. Full detail: `volumes/sillytavern/data/default-user/OpenAI Settings/`. - **Helper**: added `just sillytavern-preset-copy path/to/preset.json` as a thin wrapper around that direct copy. Actual preset exports are user data and should not be tracked in this repo. + +## 2026-06-20 - SillyTavern image generation through Bifrost +- **Finding**: SillyTavern's built-in OpenAI and OpenRouter image sources are hardcoded to the public + provider APIs, so they cannot be pointed at Bifrost with settings alone. Its Stable Diffusion WebUI + source is URL-configurable. +- **Adapter**: added `sillytavern-bifrost-image`, a small A1111-compatible service that exposes + `/sdapi/v1/*` to SillyTavern. It can forward to Bifrost's OpenAI-compatible image endpoint or to + OpenRouter's image chat-completions endpoint. +- **SillyTavern settings**: the adapter is selected through SillyTavern's configurable Stable + Diffusion WebUI source. Concrete URLs, model choices, and model lists belong in ignored secrets or + live SillyTavern data. +- **2026-06-22 update**: switched the adapter backend for testing with provider credentials loaded + from ignored secret files. Direct provider routing avoided incompatible chat-tool injection. +- **2026-06-22 model switch**: model choices were moved into ignored runtime env/config rather than + tracked repo files. +- **Verification**: SillyTavern can reach the adapter health/options/model endpoints. A real `txt2img` + request returned HTTP 200 and wrote a valid smoke image under runtime output storage. + +## 2026-06-22 - SillyTavern image generation through external ComfyUI +- **Finding**: the external ComfyUI endpoint is reachable from devserver. The concrete endpoint and + model inventory belong in ignored env/config, not tracked notes. +- **Adapter update**: extended `sillytavern-bifrost-image` with `IMAGE_BACKEND=comfyui`. It exposes + the same A1111-shaped `/sdapi/v1/txt2img` interface and translates requests into a simple ComfyUI + checkpoint → CLIP encode → KSampler → VAE decode → SaveImage workflow. +- **Runtime config**: concrete endpoint, model, sampler, and scheduler values are loaded from + ignored env files or SillyTavern's live user settings. +- **Verification**: direct ComfyUI API generation and adapter generation both succeeded during setup. +- **2026-06-22 `/imagine me` fix**: SillyTavern's prompt-generation step for `/imagine me` failed + when the current chat contained a generated image attachment because `oai_settings.media_inlining` + was true and the active DeepSeek/OpenRouter chat model does not support image input. Set + `media_inlining=false`, restarted SillyTavern, and kept ComfyUI image generation unchanged. + Also added no-op A1111 metadata endpoints for `sd-vae`, `sd-modules`, and `latent-upscale-modes` + to avoid harmless SillyTavern probe 404s. +- **2026-06-22 timeout test**: `/imagine scene` can stall before ComfyUI because SillyTavern first + asks the main chat model to convert the scene into image tags. Increased Bifrost's provider + request timeout in rendered config and live state, then restarted Bifrost. +- **2026-06-22 model switch fix**: SillyTavern changes Stable Diffusion WebUI checkpoints by POSTing + `sd_model_checkpoint` to `/sdapi/v1/options`; the adapter previously returned success but ignored + that state, so `/txt2img` fell back to its default model. The adapter now stores the current + checkpoint, reports it from GET `/options`, and uses it for generation when no per-request model + override is present. Compose no longer stores concrete model defaults. + +## 2026-06-22 - SillyTavern ComfyUI workflows +- **Goal**: copy external ComfyUI API workflows into SillyTavern's live workflow directory. +- **Installed files**: workflow JSON files were copied into live SillyTavern user data. Concrete + workflow names and source endpoints should stay in ignored runtime data, not tracked notes. +- **Active workflow**: SillyTavern image generation was pointed at the copied ComfyUI workflow with + prompt expansion disabled. +- **Verification**: the active workflow was submitted directly to ComfyUI and returned a valid image. + +## 2026-06-23 - SillyTavern image adapter privacy cleanup +- **Goal**: keep the adapter implementation in the shared Docker image project and keep concrete + image backend settings out of tracked devserver files. +- **Move**: the adapter build context now points at `/projects/dockers`; devserver only keeps the + Compose service wiring and ignored env-file reference. +- **Privacy rule**: concrete image model IDs, ComfyUI workflow filenames, private endpoint names, and + private network addresses belong in ignored secrets or live app data, not tracked repo files. From 5592509b4a6e55e6799bb1f4a8ccf7158b99272f Mon Sep 17 00:00:00 2001 From: Azimuth Date: Sun, 28 Jun 2026 01:12:26 -0700 Subject: [PATCH 13/13] chore(devserver): add bifrost cache fix watcher, LLM API matrix, and log ignore rules - Add bifrost-check-fix and bifrost-watch Just recipes with check_bifrost_cache_fix.py tool - Add sillytavern-image-adapter-up/logs/test Just recipes for the image adapter - Add llm-api-matrix Just recipe with scripts/llm_api_matrix.py for probing Studio/Bifrost/llama - Add logs/* ignore rule with logs/.gitkeep exception to .gitignore Azimuth-Run: autosweep_20260628T080300Z_e56f16a3 --- .gitignore | 2 + Justfile | 25 ++ scripts/llm_api_matrix.py | 438 +++++++++++++++++++++++++++++++ tools/check_bifrost_cache_fix.py | 134 ++++++++++ 4 files changed, 599 insertions(+) create mode 100644 scripts/llm_api_matrix.py create mode 100755 tools/check_bifrost_cache_fix.py diff --git a/.gitignore b/.gitignore index e9f4b56..a626cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ volumes/ secrets/ .env +logs/* +!logs/.gitkeep # Editor / local tooling .vscode diff --git a/Justfile b/Justfile index 453519a..baf4de9 100644 --- a/Justfile +++ b/Justfile @@ -47,6 +47,16 @@ restart *args: logs *args: {{COMPOSE}} logs -f {{args}} +# Check whether a published Bifrost image yet contains the MCP tool-ordering +# cache fix (PR #4588). Exit 0 = available to pull; 10 = not yet. +bifrost-check-fix: + uv run tools/check_bifrost_cache_fix.py + +# Leave this running in a tab: polls until the Bifrost cache fix ships, then +# prints a banner + rings the bell. Optional interval seconds (default 3600). +bifrost-watch *interval: + uv run tools/check_bifrost_cache_fix.py --watch {{interval}} + build *args: {{COMPOSE}} build {{args}} @@ -199,6 +209,16 @@ sillytavern-preset-copy file: mkdir -p "volumes/sillytavern/data/default-user/OpenAI Settings" cp "{{file}}" "volumes/sillytavern/data/default-user/OpenAI Settings/" +# Start/rebuild the SillyTavern image-generation adapter backed by Bifrost. +sillytavern-image-adapter-up: + {{COMPOSE}} up -d --build sillytavern-bifrost-image + +sillytavern-image-adapter-logs: + {{COMPOSE}} logs -f sillytavern-bifrost-image + +sillytavern-image-adapter-test: + python3 /projects/dockers/sillytavern-bifrost-image-adapter/smoke_test.py + # List the MCP tools Bifrost discovered from mcpproxy (exa search + websets). bifrost-mcp-tools: curl -fsS {{BIFROST_URL}}/api/mcp/clients | python3 -c 'import sys,json; [ (print("client",c["config"]["name"]+":"), [print(" -",t["name"]) for t in c.get("tools",[])]) for c in json.load(sys.stdin)["clients"]]' @@ -212,6 +232,11 @@ bifrost-test-search model="nvidia/meta/llama-3.1-8b-instruct" *query="What is th -d '{"model":"{{model}}","messages":[{"role":"user","content":"Use the web_search tool, then: {{query}}"}],"max_tokens":400}' \ | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["choices"][0]["message"].get("content") if d.get("choices") else "ERR "+json.dumps(d.get("error",{})))' +# Probe Studio/Bifrost/direct llama behavior across Chat Completions vs +# Responses and stream=true/false. Pass script flags after the recipe name. +llm-api-matrix *args: + python3 scripts/llm_api_matrix.py {{args}} + # Wipe Bifrost runtime state (config.db + logs.db). Forces a clean re-seed from # config/bifrost.config.json on next start. Does NOT touch the config file itself. bifrost-reset: diff --git a/scripts/llm_api_matrix.py b/scripts/llm_api_matrix.py new file mode 100644 index 0000000..d60304c --- /dev/null +++ b/scripts/llm_api_matrix.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Probe OpenAI-compatible Chat Completions and Responses API behavior. + +This is intentionally dependency-free so it can run on the Linux host, the +Windows box, or inside a simple container. +""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlsplit + + +DEFAULT_PROMPT = ( + "Think briefly, then answer with exactly one short sentence. " + "What is 3 + 5?" +) + + +@dataclass(frozen=True) +class Target: + name: str + base_url: str + model: str + api_key: str | None + + +def normalize_base_url(raw_url: str, target: str) -> str: + url = raw_url.rstrip("/") + path = urlsplit(url).path.rstrip("/") + if path.endswith("/v1") or path.endswith("/openai/v1"): + return url + if target == "bifrost": + return f"{url}/openai/v1" + return f"{url}/v1" + + +def parse_bool_list(value: str) -> list[bool]: + if value == "both": + return [False, True] + if value in {"true", "1", "yes", "stream"}: + return [True] + if value in {"false", "0", "no", "nonstream", "non-stream"}: + return [False] + raise argparse.ArgumentTypeError("expected true, false, or both") + + +def parse_api_list(value: str) -> list[str]: + if value == "both": + return ["chat", "responses"] + parts = [part.strip() for part in value.split(",") if part.strip()] + bad = [part for part in parts if part not in {"chat", "responses"}] + if bad: + raise argparse.ArgumentTypeError(f"unknown API type(s): {', '.join(bad)}") + return parts + + +def make_payload(api_type: str, model: str, stream: bool, args: argparse.Namespace) -> dict[str, Any]: + if api_type == "chat": + payload: dict[str, Any] = { + "model": model, + "messages": [{"role": "user", "content": args.prompt}], + "max_tokens": args.max_tokens, + "stream": stream, + } + else: + payload = { + "model": model, + "input": args.prompt, + "max_output_tokens": args.max_tokens, + "stream": stream, + } + + if args.enable_thinking != "omit": + payload["enable_thinking"] = args.enable_thinking == "true" + if args.extra_json: + try: + extra = json.loads(args.extra_json) + except json.JSONDecodeError as exc: + raise SystemExit(f"--extra-json is not valid JSON: {exc}") from exc + if not isinstance(extra, dict): + raise SystemExit("--extra-json must decode to a JSON object") + payload.update(extra) + return payload + + +def request_once( + target: Target, + api_type: str, + stream: bool, + args: argparse.Namespace, +) -> tuple[int | None, str, bytes, float]: + endpoint = "chat/completions" if api_type == "chat" else "responses" + url = f"{target.base_url}/{endpoint}" + payload = make_payload(api_type, target.model, stream, args) + headers = { + "Content-Type": "application/json", + "Accept": "text/event-stream" if stream else "application/json", + } + if target.api_key: + headers["Authorization"] = f"Bearer {target.api_key}" + + req = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers=headers, + method="POST", + ) + + started = time.monotonic() + try: + with urllib.request.urlopen(req, timeout=args.timeout) as resp: + content_type = resp.headers.get("content-type", "") + if stream or "text/event-stream" in content_type.lower(): + body = read_stream_body(resp, args.max_bytes) + else: + body = resp.read(args.max_bytes) + return resp.status, content_type, body, time.monotonic() - started + except urllib.error.HTTPError as exc: + body = exc.read(args.max_bytes) + content_type = exc.headers.get("content-type", "") + return exc.code, content_type, body, time.monotonic() - started + except urllib.error.URLError as exc: + return None, "transport-error", str(exc).encode("utf-8"), time.monotonic() - started + + +def read_stream_body(resp: Any, max_bytes: int) -> bytes: + chunks: list[bytes] = [] + total = 0 + + while total < max_bytes: + try: + line = resp.readline() + except (TimeoutError, socket.timeout): + chunks.append(b": client_read_timeout\n\n") + break + if not line: + break + chunks.append(line) + total += len(line) + stripped = line.strip() + if stripped in {b"data: [DONE]", b"data:[DONE]"}: + break + + if total >= max_bytes: + chunks.append(b"\n: client_max_bytes_reached\n\n") + return b"".join(chunks) + + +def iter_sse_events(body: bytes) -> list[tuple[str | None, str]]: + text = body.decode("utf-8", errors="replace") + events: list[tuple[str | None, str]] = [] + event_name: str | None = None + data_lines: list[str] = [] + + for raw_line in text.splitlines(): + line = raw_line.rstrip("\r") + if not line: + if data_lines: + events.append((event_name, "\n".join(data_lines))) + event_name = None + data_lines = [] + continue + if line.startswith(":"): + continue + if line.startswith("event:"): + event_name = line.removeprefix("event:").strip() + elif line.startswith("data:"): + data_lines.append(line.removeprefix("data:").strip()) + + if data_lines: + events.append((event_name, "\n".join(data_lines))) + return events + + +def extract_text_bits(api_type: str, obj: dict[str, Any]) -> tuple[str, str]: + content: list[str] = [] + reasoning: list[str] = [] + + if api_type == "chat": + for choice in obj.get("choices", []) or []: + message = choice.get("message") or {} + delta = choice.get("delta") or {} + for source in (message, delta): + for key in ("content", "text"): + value = source.get(key) + if isinstance(value, str): + content.append(value) + for key in ("reasoning_content", "reasoning", "reasoning_text"): + value = source.get(key) + if isinstance(value, str): + reasoning.append(value) + else: + for item in obj.get("output", []) or []: + for part in item.get("content", []) or []: + value = part.get("text") + if isinstance(value, str): + if part.get("type") in {"reasoning_text", "reasoning"}: + reasoning.append(value) + else: + content.append(value) + for key in ("output_text", "text"): + value = obj.get(key) + if isinstance(value, str): + content.append(value) + + return "".join(content), "".join(reasoning) + + +def summarize_json(api_type: str, body: bytes) -> dict[str, Any]: + try: + obj = json.loads(body.decode("utf-8", errors="replace")) + except json.JSONDecodeError as exc: + return {"json_error": str(exc), "preview": preview(body)} + + content, reasoning = extract_text_bits(api_type, obj) + return { + "object": obj.get("object"), + "model": obj.get("model"), + "error": obj.get("error"), + "content_preview": trim(content), + "reasoning_preview": trim(reasoning), + "raw_preview": trim(json.dumps(obj, ensure_ascii=False)), + } + + +def summarize_sse(api_type: str, body: bytes, max_events: int) -> dict[str, Any]: + events = iter_sse_events(body) + event_names: list[str] = [] + content: list[str] = [] + reasoning: list[str] = [] + raw_samples: list[str] = [] + + for event_name, data in events: + if data == "[DONE]": + event_names.append(event_name or "message") + continue + event_names.append(event_name or "message") + if len(raw_samples) < max_events: + raw_samples.append(f"{event_name or 'message'}: {trim(data, 240)}") + try: + obj = json.loads(data) + except json.JSONDecodeError: + continue + + if api_type == "responses": + response_type = obj.get("type") or event_name + delta = obj.get("delta") + if response_type in {"response.reasoning_text.delta", "response.reasoning.delta"}: + if isinstance(delta, str): + reasoning.append(delta) + elif response_type in {"response.output_text.delta", "response.text.delta"}: + if isinstance(delta, str): + content.append(delta) + else: + c, r = extract_text_bits(api_type, obj) + content.append(c) + reasoning.append(r) + + return { + "events": len(events), + "event_names": compact_counts(event_names), + "content_preview": trim("".join(content)), + "reasoning_preview": trim("".join(reasoning)), + "samples": raw_samples, + } + + +def compact_counts(values: list[str]) -> str: + counts: dict[str, int] = {} + for value in values: + counts[value] = counts.get(value, 0) + 1 + return ", ".join(f"{key}={value}" for key, value in counts.items()) + + +def trim(text: str | None, limit: int = 500) -> str: + if not text: + return "" + text = " ".join(text.split()) + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def preview(body: bytes, limit: int = 1000) -> str: + return trim(body.decode("utf-8", errors="replace"), limit) + + +def print_result( + target: Target, + api_type: str, + requested_stream: bool, + status: int | None, + content_type: str, + body: bytes, + elapsed: float, + args: argparse.Namespace, +) -> None: + print(f"\n== {target.name} | {api_type} | stream={str(requested_stream).lower()} ==") + print(f"url: {target.base_url}") + print(f"model: {target.model}") + print(f"status: {status} elapsed: {elapsed:.2f}s content-type: {content_type or '-'}") + + lowered_type = content_type.lower() + looks_sse = "text/event-stream" in lowered_type or body.lstrip().startswith(b"data:") + if not requested_stream and looks_sse: + print("note: server returned SSE even though stream=false") + if b"client_read_timeout" in body: + print("note: client read timed out before the stream sent [DONE]") + if b"client_max_bytes_reached" in body: + print("note: client stopped after --max-bytes") + + if status is None: + print(f"transport_error: {preview(body)}") + elif looks_sse: + summary = summarize_sse(api_type, body, args.max_events) + print(f"events: {summary['events']} event_names: {summary['event_names'] or '-'}") + if summary["reasoning_preview"]: + print(f"reasoning: {summary['reasoning_preview']}") + if summary["content_preview"]: + print(f"content: {summary['content_preview']}") + if args.show_samples: + for sample in summary["samples"]: + print(f"sample: {sample}") + elif "json" in lowered_type or body.lstrip().startswith((b"{", b"[")): + summary = summarize_json(api_type, body) + if summary.get("error"): + print(f"error: {json.dumps(summary['error'], ensure_ascii=False)}") + if summary.get("reasoning_preview"): + print(f"reasoning: {summary['reasoning_preview']}") + if summary.get("content_preview"): + print(f"content: {summary['content_preview']}") + if args.show_samples: + print(f"raw: {summary['raw_preview']}") + elif summary.get("json_error"): + print(f"json_error: {summary['json_error']}") + print(f"raw: {summary['preview']}") + else: + print(f"body: {preview(body)}") + + +def build_targets(args: argparse.Namespace) -> list[Target]: + configured = { + "studio": Target( + "studio", + normalize_base_url(args.studio_url, "studio"), + args.studio_model, + args.studio_key or None, + ), + "bifrost": Target( + "bifrost", + normalize_base_url(args.bifrost_url, "bifrost"), + args.bifrost_model, + args.bifrost_key or None, + ), + } + + if args.llama_url: + configured["llama"] = Target( + "llama", + normalize_base_url(args.llama_url, "llama"), + args.llama_model, + args.llama_key or None, + ) + + names = args.target + if names == ["all"]: + names = ["studio", "bifrost"] + (["llama"] if "llama" in configured else []) + + missing = [name for name in names if name not in configured] + if missing: + raise SystemExit( + "missing target config for " + + ", ".join(missing) + + ". For direct llama, pass --llama-url or set LLAMA_BASE_URL." + ) + return [configured[name] for name in names] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Test chat/responses + streaming/non-streaming across Studio, Bifrost, and llama-server.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("--target", action="append", choices=["all", "studio", "bifrost", "llama"], default=None) + parser.add_argument("--api", type=parse_api_list, default=parse_api_list("both"), help="chat, responses, both, or comma list") + parser.add_argument("--stream", type=parse_bool_list, default=parse_bool_list("both"), help="true, false, or both") + parser.add_argument("--prompt", default=DEFAULT_PROMPT) + parser.add_argument("--max-tokens", type=int, default=160) + parser.add_argument("--timeout", type=float, default=60) + parser.add_argument("--max-bytes", type=int, default=1_000_000) + parser.add_argument("--max-events", type=int, default=8) + parser.add_argument("--show-samples", action="store_true") + parser.add_argument("--enable-thinking", choices=["omit", "true", "false"], default="omit") + parser.add_argument("--extra-json", help="JSON object merged into every request payload") + + parser.add_argument("--studio-url", default=os.getenv("UNSLOTH_STUDIO_URL", "http://desktop-win:8888")) + parser.add_argument("--studio-model", default=os.getenv("UNSLOTH_MODEL", "unsloth/default")) + parser.add_argument("--studio-key", default=os.getenv("UNSLOTH_STUDIO_API_KEY")) + + parser.add_argument("--bifrost-url", default=os.getenv("BIFROST_OPENAI_URL", os.getenv("BIFROST_URL", "http://127.0.0.1:8090"))) + parser.add_argument("--bifrost-model", default=os.getenv("BIFROST_MODEL", "unsloth/default")) + parser.add_argument("--bifrost-key", default=os.getenv("BIFROST_API_KEY")) + + parser.add_argument("--llama-url", default=os.getenv("LLAMA_BASE_URL")) + parser.add_argument("--llama-model", default=os.getenv("LLAMA_MODEL", "default")) + parser.add_argument("--llama-key", default=os.getenv("LLAMA_API_KEY")) + + args = parser.parse_args() + args.target = args.target or ["all"] + return args + + +def main() -> int: + args = parse_args() + targets = build_targets(args) + failures = 0 + + for target in targets: + for api_type in args.api: + for stream in args.stream: + status, content_type, body, elapsed = request_once(target, api_type, stream, args) + print_result(target, api_type, stream, status, content_type, body, elapsed, args) + if status is None or status >= 400: + failures += 1 + + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/check_bifrost_cache_fix.py b/tools/check_bifrost_cache_fix.py new file mode 100755 index 0000000..d44ee92 --- /dev/null +++ b/tools/check_bifrost_cache_fix.py @@ -0,0 +1,134 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""Watch for a published Bifrost image that contains the MCP tool-ordering cache fix. + +Background: PR #4588 ("deterministic MCP tool ordering for prompt cache stability") +merged to `dev` at commit 1cdd311 on 2026-06-21 14:49 UTC, AFTER the v1.5.16 image +(and therefore `:latest`) was cut at 13:10. Until a newer image is published, pulling +`maximhq/bifrost:latest` does NOT get the fix. + +This script answers one question: "Is there now a published image I can pull that +contains commit 1cdd311?" It does so without trusting timestamps: + 1. Find the newest `transports/v*` release tag on GitHub (that's what `:latest` tracks). + 2. Ask GitHub's compare API whether the fix commit is contained in that tag. + 3. Report the current Docker Hub `:latest` digest and our running digest. + +Exit code 0 = fix is available to pull; 10 = not yet; 1 = error. + +Usage: + ./check_bifrost_cache_fix.py # one-shot check + ./check_bifrost_cache_fix.py --watch # loop until the fix is published, then + # print a loud banner and exit (leave in a tab) + ./check_bifrost_cache_fix.py --watch 1800 # custom poll interval in seconds (default 3600) +""" +import json +import sys +import time +import urllib.request +import urllib.error + +FIX_COMMIT = "1cdd311fdfb3017e3802b3f477c69261bc5dc971" +REPO = "maximhq/bifrost" +PIN_FILE_NOTE = "config in docker-compose.yml: image: maximhq/bifrost:latest" + + +def gh(path): + req = urllib.request.Request( + f"https://api.github.com/repos/{REPO}/{path}", + headers={"Accept": "application/vnd.github+json", "User-Agent": "bifrost-watch"}, + ) + with urllib.request.urlopen(req, timeout=30) as r: + return json.load(r) + + +def hub_latest(): + req = urllib.request.Request( + f"https://hub.docker.com/v2/repositories/{REPO}/tags/latest", + headers={"User-Agent": "bifrost-watch"}, + ) + with urllib.request.urlopen(req, timeout=30) as r: + d = json.load(r) + return d.get("digest"), d.get("last_updated") + + +def newest_transports_tag(): + tags = gh("tags?per_page=100") + trans = [t["name"] for t in tags if t["name"].startswith("transports/v")] + + def key(name): + nums = name.split("/v", 1)[1].split(".") + return tuple(int(x) for x in nums if x.isdigit()) + + trans.sort(key=key, reverse=True) + return trans[0] if trans else None + + +def main(): + try: + tag = newest_transports_tag() + if not tag: + print("ERROR: no transports/v* tags found", file=sys.stderr) + return 1 + cmp = gh(f"compare/{tag}...{FIX_COMMIT}") + status = cmp.get("status") # "behind"/"identical" => fix is in the tag + contained = status in ("behind", "identical") + digest, updated = hub_latest() + + print(f"newest release tag : {tag}") + print(f"fix commit : {FIX_COMMIT[:12]} (PR #4588)") + print(f"compare status : {tag}...fix = {status} " + f"(ahead_by={cmp.get('ahead_by')}, behind_by={cmp.get('behind_by')})") + print(f"docker :latest : {digest} (updated {updated})") + print(f"running : {PIN_FILE_NOTE}") + print() + if contained: + print(f"✅ FIX AVAILABLE — {tag} contains the cache-ordering fix.") + print(" Upgrade: cd ~/hroot/devserver && docker pull maximhq/bifrost:latest " + "&& just up bifrost") + print(" Then re-probe outbound tool order (should be stable + alphabetical).") + return 0 + else: + print(f"⏳ NOT YET — newest release {tag} predates the fix " + f"(fix is {cmp.get('ahead_by')} commits ahead). Keep waiting.") + return 10 + except urllib.error.URLError as e: + print(f"ERROR: network/API failure: {e}", file=sys.stderr) + return 1 + + +def watch(interval): + """Poll until the fix is published; print a banner and return 0. Ctrl-C to stop.""" + print(f"watching maximhq/bifrost for the cache fix (PR #4588) — polling every " + f"{interval}s. Ctrl-C to stop.\n") + n = 0 + while True: + n += 1 + stamp = time.strftime("%Y-%m-%d %H:%M:%S") + print(f"──── check #{n} @ {stamp} ────") + code = main() + if code == 0: + print("\n" + "█" * 60) + print("█ BIFROST CACHE FIX IS NOW PUBLISHED — TIME TO UPGRADE █") + print("█ cd ~/hroot/devserver && docker pull maximhq/bifrost:latest") + print("█ && just up bifrost (then re-probe MCP tool order)") + print("█" * 60) + sys.stdout.write("\a") # terminal bell + sys.stdout.flush() + return 0 + print(f"(sleeping {interval}s)\n", flush=True) + try: + time.sleep(interval) + except KeyboardInterrupt: + print("\nstopped.") + return 130 + + +if __name__ == "__main__": + args = sys.argv[1:] + if args and args[0] == "--watch": + ivl = int(args[1]) if len(args) > 1 and args[1].isdigit() else 3600 + sys.exit(watch(ivl)) + sys.exit(main())