Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
volumes/
secrets/
.env
logs/*
!logs/.gitkeep

# Editor / local tooling
.vscode
Expand Down
55 changes: 55 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}}

Expand Down Expand Up @@ -143,6 +153,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
Expand All @@ -169,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"]]'
Expand All @@ -182,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:
Expand Down
75 changes: 69 additions & 6 deletions config/bifrost.config.json.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@
]
},

"plugins": [
{
"enabled": true,
"name": "model-policy-suffix",
"path": "/app/plugins/model-policy-suffix.so",
"version": 1
}
],

"providers": {
"openrouter": {
"keys": [
Expand All @@ -56,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
}
},

Expand All @@ -86,6 +91,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": [
{
Expand All @@ -96,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
Expand All @@ -120,7 +153,7 @@
{
"name": "deepseek-key-1",
"value": "env.DEEPSEEK_API_KEY",
"models": ["deepseek-chat", "deepseek-reasoner"],
"models": ["*"],
"weight": 1.0
}
],
Expand All @@ -137,6 +170,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": [
{
Expand Down
3 changes: 3 additions & 0 deletions config/bifrost.env.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@ 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
# codex-oauth proxy needs no auth (internal, mybridge-only); Bifrost still requires a key entry.
CODEX_PROXY_API_KEY=none
32 changes: 32 additions & 0 deletions config/openclaw.config.patch.json.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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" }
]
}
}
}
}
14 changes: 13 additions & 1 deletion config/openrouter-presets.json
Original file line number Diff line number Diff line change
@@ -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/<slug>` (or `openrouter/@preset/<slug>` 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",
Expand All @@ -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
}
}
}
]
}
3 changes: 3 additions & 0 deletions config/pipeline-dagster.env.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 6 additions & 2 deletions docker-compose.pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading