Heimdall was created to work around a practical VS Code Copilot BYOK limitation: Copilot can use custom keys and endpoints, but configuring the same model through two different upstream vendors can cause conflicts.
This project puts one local OpenAI-compatible endpoint in front of those vendors. The client sees a single stable chat-completions endpoint, while the router handles vendor priority and fallback locally.
http://127.0.0.1:4000/v1/chat/completions
The router tries enabled vendors in priority order. If the current vendor times out, returns a retryable status, or fails before a response is streamed, the next vendor is tried.
- OpenAI-compatible
/v1/chat/completionsproxy. - Local vendor priority and fallback configuration.
- Per-vendor request format: Chat Completions, Responses, Embeddings, or a Custom path.
- Local API key required for access.
- Electron GUI for configuration, status, token usage, estimated cost, logs, and tray control.
- Optional login startup on Windows and macOS, disabled by default.
- Structured logs without API keys, authorization headers, secrets, or upstream response bodies.
- Node.js 22.12 or newer.
- Windows or macOS for desktop development; formal installers are built in CI on their target operating system.
- At least one upstream OpenAI-compatible chat-completions provider.
git clone https://github.com/landfallbox/heimdall.git
cd heimdall
npm install
copy config.example.json config.json
npm run guinpm run gui starts the development GUI with an isolated config directory under the system temp folder. This keeps development runs separate from the project config.json and uses a development default port when it creates a new config.
To point the development GUI at the project config instead, set ROUTER_CONFIG before starting it:
$env:ROUTER_CONFIG = (Resolve-Path .\config.json)
npm run guiIn the GUI, set:
router.apiKey: the local token clients send asAuthorization: Bearer ....vendors: upstream providers in priority order.baseUrl: the upstream API base URL, usually ending in/v1.models[].id: a model name this vendor can serve.models[].pricing: optional pricing mode.customuses explicit input, cached-input, and output prices per one million tokens;openai,deepseek,anthropic,google,kimi,glm, andmimoselect the built-in provider catalog for the model id.authentication:noneorapi-key.models[].enableThinking: optional, defaultfalse. Whentrue, the Router addschat_template_kwargs.enable_thinking: trueto upstream requests for that model. This only works for models served by a vLLM endpoint that supports the parameter; keep it off for any other vendor or deployment.
Each vendor can support multiple models. Requests are routed only to vendors that list the requested model id, and the same model id is sent to the selected upstream provider.
The Usage page reports daily, weekly, and monthly input/output token totals, a 30-day trend, and current-month vendor/model breakdowns. Known model ids use the standard API prices published by each provider; the bundled catalogs (OpenAI, DeepSeek, Anthropic, Google Gemini, Kimi, GLM, Xiaomi MiMo) are generated from data/pricing.json into src/pricing-data.ts and record their source URL and update date there. DeepSeek moved to peak/off-peak billing on 2026-08-16, so DeepSeek estimates pick the peak or off-peak rate card from the request's UTC hour (peak hours 01:00-04:00 and 06:00-10:00 UTC). Select Custom in Vendor Settings to override prices or price models outside the built-in catalogs. Each event stores its price snapshot so later configuration changes do not rewrite historical estimates.
Catalogs can be updated without shipping a new app release. data/pricing.json is the single source of truth for model pricing. The Router fetches it from the repository (override the URL with HEIMDALL_PRICING_URL), caches it as pricing-cache.json next to config.json, and re-checks every 24 hours; when the fetch fails, the last cache and the bundled tables keep working. To publish a price change, edit data/pricing.json, bump its top-level updatedAt (YYYY-MM-DD), run npm run pricing:generate to refresh the bundled fallback, and commit both files — remote entries override the built-in catalog per model, and built-in entries are never removed. The GUI shows the effective catalog source on the Application page.
Keep config.json private. It is ignored by git and may contain API keys. The desktop app manages the Router process and performs health checks internally.
Point any OpenAI-compatible client at the local endpoint and use the same token as router.apiKey.
The Router accepts POST /v1/chat/completions, POST /v1/responses, and POST /v1/embeddings. Each vendor can independently use an upstream request format; the desktop app exposes this choice in Vendor Settings:
- Chat Completions — forwards to the vendor's
/chat/completionspath (configurable viachatCompletionsPath). - Responses — forwards to the vendor's
/responsespath (configurable viaresponsesPath). - Embeddings — forwards to the vendor's
/embeddingspath (configurable viaembeddingsPath). - Custom — forwards directly to the vendor Base URL, which is the complete request path (not a base to append a path to). Use this when the upstream endpoint does not match any of the built-in formats, for example
http://127.0.0.1:8002/v1/embeddings.
Chat Completions and Responses bodies are translated automatically when the inbound and upstream formats differ. Embeddings and Custom bodies are passed through unchanged.
Example client entry:
{
"name": "Heimdall",
"vendor": "customendpoint",
"apiKey": "replace-with-your-local-router-token",
"apiType": "chat-completions",
"models": [
{
"id": "model-id",
"name": "Model Name",
"url": "http://127.0.0.1:4000/v1/chat/completions",
"toolCalling": true,
"vision": true,
"maxInputTokens": 200000,
"maxOutputTokens": 64000
}
]
}Instead of hand-editing VS Code's chatLanguageModels.json, the GUI can write the file for you. On the Application page, turn on Sync models to VS Code. From then on, every time you save the configuration, Heimdall merges your enabled models into VS Code's file under a dedicated Heimdall provider (the router base URL plus /v1, and your router.apiKey).
The merge is conservative:
- Other providers in the file (for example VS Code's own
Copilotprovider) and their settings are left untouched. - A model's existing metadata is kept; Heimdall only overrides the fields it explicitly provides.
- The merge is additive only: models you disable or remove in heimdall stay in the
Heimdallprovider (as do models you add by hand). VS Code does not hot-reload this file, so a dropped entry would only disappear after a manual window reload — while risking the loss of entries heimdall does not manage. - If VS Code already stores the provider's
apiKeyas a secret reference (${input:chat.lm.secret.…}), that reference is never replaced — opaque secrets can only be managed inside VS Code. - Heimdall only manages the provider-level
apiKey(the router key) and each model'surl(the router's/v1endpoint). A model you add by hand under theHeimdallprovider that points to a differenturlwill still be called with the provider-level key — if such a model needs its own key, keep it under a separate provider.
Per-model VS Code metadata (display name, tool calling, vision, thinking, context window, max output tokens, supported reasoning effort) is optional. Expand VS Code metadata on a model row in Vendor Settings to fill it in; only the fields you set are written, so a partial entry never clobbers richer metadata you already have in the file.
The target file follows VS Code's per-platform user settings directory (%APPDATA%\Code\User\chatLanguageModels.json on Windows, ~/Library/Application Support/Code/User/chatLanguageModels.json on macOS, ~/.config/Code/User/chatLanguageModels.json on Linux) and can be overridden with HEIMDALL_VSCODE_MODELS_FILE. A Sync now button on the Application page runs the merge immediately without changing the configuration.
- Fallback is enabled for request timeouts, network failures before a response,
408,409,425,429,500,502,503,504, and other5xxresponses. - Fallback is not enabled by default for
400,401,403, or404, because those usually indicate configuration, authentication, or request-format problems. - Circuit breaking is tracked independently for each vendor and model. Two consecutive fallback-eligible failures open the circuit for 10 seconds. Repeated failed probes increase that duration linearly up to 60 seconds; one successful probe restores the configured vendor priority.
- When every matching vendor has an open circuit, the Router probes the vendor whose circuit expires first instead of immediately reporting that no vendor is available.
- Saved Router credentials, limits, fallback settings, vendors, priorities, and models are applied to new requests without restarting the Router. Requests already in progress continue with the configuration snapshot they started with.
- Changes to
router.host,router.port, orrouter.logFilerequire a Router restart. Other changes in the same save are still applied immediately. - Manual edits to
config.jsonare detected automatically. Invalid or incomplete edits are logged and ignored, so the Router keeps serving with its last valid configuration. - Requests and responses are forwarded unchanged when the client and vendor use the same format, including streaming. For different formats, the Router converts common non-streaming text and function-call payloads. Cross-format streaming and format-specific parameters that cannot be converted safely return
400instead of being silently discarded. - If an upstream fails before streaming starts, another vendor can be tried. Once partial output has reached the client, the router cannot switch vendors without corrupting the stream.
- Token totals depend on the upstream response's
usagemetadata. Streaming providers that omit final usage are counted as requests but excluded from token and cost totals; the Usage page displays token and price coverage instead of treating missing metadata as zero. - Usage events contain request metadata and token counts, not prompts or generated content. They are stored as monthly JSONL files under the data directory's
usagefolder. - Packaged builds keep every local file in one data directory: configuration, logs, usage journals, and Electron state. The Windows installer asks where to put it, and the Application settings page can move it later; moving stops the Router, copies the existing files, verifies them, and restarts the app.
- The chosen directory is recorded in a pointer file that always stays in the default location, so the app can find it on the next start:
- Windows:
%APPDATA%\Heimdall\data-location.ini, with%APPDATA%\Heimdallas the default data directory. - macOS:
~/Library/Application Support/Heimdall/data-location.ini. The drag-to-install disk image has no installer page, so the directory is only changed from settings.
- Windows:
- If a recorded directory is unavailable at startup (an external drive that is not plugged in, for example) the app reports it and uses the default folder for that session instead of creating a second copy of the configuration. The pointer is kept, so the data is found again once the directory is reachable. A structurally invalid pointer (a relative path, a drive root, or a folder inside the installation directory) can never become valid, so the app resets it on startup and reports the reset.
The main runtime boundaries are:
src/server.ts: HTTP routing, upstream failover, and stream ownership.src/vendor-circuit-breaker.ts: per-vendor, per-model passive failure tracking and half-open recovery.src/runtime-config.ts: file and environment configuration for the Router process.src/logger.ts: structured logging and recursive secret redaction.src/usage.ts: usage normalization, provider catalog wiring (OpenAI, DeepSeek peak/off-peak, Anthropic, Google, Kimi, GLM, MiMo), and custom token pricing.data/pricing.json/src/pricing-data.ts: the single source of truth for model pricing and the generated bundled fallback it produces (npm run pricing:generate).src/pricing-updater.ts: remote pricing fetch,pricing-cache.jsonpersistence, and the 24-hour refresh loop.src/usage-store.ts: monthly JSONL persistence and local-calendar aggregation.gui/electron/main.ts: Electron lifecycle, tray, IPC registration, and orchestration.gui/electron/config-store.ts: validated, revision-checked, atomic configuration writes.gui/electron/data-location.ts: data directory pointer, target validation, and file migration.gui/electron/log-store.ts: byte-cursor log pagination.src/vscode-sync.ts: builds the model entries from the config and merges them into VS Code'schatLanguageModels.json(other providers, secret references, and existing metadata are preserved).gui/src/config-draft.ts: lossless conversion between persisted configuration and form state.
The Router process is owned by the Electron main process and does not outlive an explicit app exit. The current app session uses a private parent-child IPC channel for graceful shutdown, with forced termination only as a timeout fallback. PID metadata and instance identity are retained for recovery after an abnormal app exit.
Build the renderer:
npm run gui:buildBuild an unpacked Windows app:
npm run dist:windows:dirBuild the Windows Inno Setup installer (requires Inno Setup 6; ISCC is picked up from PATH or the standard install locations):
npm run dist:windowsBuild an unpacked macOS Universal app on macOS:
npm run dist:mac:dirBuild the macOS Universal DMG and ZIP installers on macOS:
npm run dist:macPackaged Windows and macOS builds check GitHub Releases for updates after startup. The app only checks for a newer version automatically; users click the update button before the update is downloaded.
For auto-update to work, each GitHub Release must include the Windows installer .exe and latest.yml plus the macOS .dmg, .zip, .zip.blockmap, and latest-mac.yml from the release directory. Windows downloads the installer asset directly from the release (no blockmap needed); macOS uses the standard electron-updater feed.
Windows installations made with the old NSIS installer (releases up to 0.16.1) check updates through the latest.yml feed. Inno-based releases keep publishing that feed pointing at the Inno installer, so old installs see the usual in-app "update available" notice and upgrade with one click — the Inno installer upgrades in place and keeps the data directory. (The current updater ignores latest.yml; it is only consumed by legacy NSIS installs.)
Tag pushes matching v* build both platforms and publish their artifacts in one GitHub Release. macOS builds are Universal binaries for Intel and Apple Silicon. To produce signed and notarized public macOS releases, configure these repository secrets:
MAC_CSC_LINK: Base64-encoded Developer ID Application certificate or certificate URL.MAC_CSC_KEY_PASSWORD: certificate password.APPLE_ID: Apple developer account email.APPLE_APP_SPECIFIC_PASSWORD: app-specific password for notarization.APPLE_TEAM_ID: Apple Developer Team ID.
Without those secrets, CI still creates an unsigned macOS package suitable for internal testing, but Gatekeeper will warn users before opening it.
Release notes are generated by an LLM before publishing. Configure the repository secret RELEASE_NOTES_API_KEY (bearer token for an OpenAI-compatible endpoint) to enable this; without it, or if generation fails, GitHub's default generated changelog is used. Optional repository variables: RELEASE_NOTES_BASE_URL (default http://122.193.22.119:8000/v1) and RELEASE_NOTES_MODEL (default Qwen3.8-27B). Generation runs with the project's Node.js runtime (scripts/generate-release-notes.mjs).
Preview the complete update UI in development mode without contacting GitHub or installing anything:
npm run gui:updateThis simulates an available 0.3.0-dev-preview release. Use the update button below the app identity in the sidebar to preview download progress, then restart-to-update state. The mock flow does not close the app or change the installed version.
Preview a download that fails after reaching partial progress:
npm run gui:update-errorClick Update available; the progress bar will stop and change to Update failed · Retry.
Set HEIMDALL_MOCK_UPDATE before npm run gui to preview a specific initial result: available, downloaded, not-available, or error. Override the preview version with HEIMDALL_MOCK_UPDATE_VERSION.
Run checks:
npm run check
npm test
npm run test:electron
npm run gui:buildnpm test contains the fast Router and repository tests. npm run test:electron launches the real Electron development app with isolated temporary configuration, verifies that it owns the Router process, verifies that hiding the window keeps Router running, and verifies that an explicit app exit stops Router. CI runs the fast and Electron lifecycle suites on both Windows and macOS.
- Keep
config.jsonprivate. - Rotate any key that appears in git history, screenshots, logs, issues, or crash reports.
- Do not bind the router to a public interface unless you understand the network exposure.
router.apiKeyis required before the router starts.
See SECURITY.md for vulnerability reporting and CONTRIBUTING.md for development guidelines.
MIT. See LICENSE.