OpenAI-Compatible LLM Endpoint (OpenRouter Support) — Design
Date: 2026-07-22
Status: Approved design, pending implementation
Scope: xylolabs-api backend only. The sibling Xylolabs-Knowledge-Engine change is specified separately in that repository (docs/superpowers/specs/2026-07-22-openrouter-endpoint-design.md).
Goal
Let operators point the backend's three LLM call sites at any OpenAI-compatible
chat/completions endpoint — primarily OpenRouter — as a deploy-time switch,
while keeping Gemini (via its OpenAI-compatibility layer) the zero-config
default. No runtime failover, no per-feature provider selection.
Background
All three LLM call sites already speak the OpenAI chat/completions wire
format against Gemini's compatibility endpoint, authenticated with
Authorization: Bearer:
| Call site | File | Uses |
|---|---|---|
| Daily report | crates/xylolabs-server/src/routes/daily_report.rs |
config.gemini_model |
| Facility assistant | crates/xylolabs-server/src/routes/facility_assistant.rs |
assistant.gemini_model registry override, falling back to config.gemini_model |
| Alert SMS contact selection | crates/xylolabs-server/src/services/alert_llm.rs |
config.gemini_model, reasoning_effort: "low" |
Each file duplicates a GEMINI_ENDPOINT const pointing at
https://generativelanguage.googleapis.com/v1beta/openai/chat/completions.
OpenRouter exposes the same request/response shape at
https://openrouter.ai/api/v1/chat/completions, so provider portability
reduces to making the endpoint URL, API key, and model configurable.
Configuration
Three new environment variables, all optional:
| Env var | Default | Meaning |
|---|---|---|
LLM_ENDPOINT |
https://generativelanguage.googleapis.com/v1beta/openai/chat/completions |
Full URL of the OpenAI-compatible chat/completions endpoint |
LLM_API_KEY |
(falls back to GEMINI_API_KEY) |
Bearer key sent to LLM_ENDPOINT |
LLM_MODEL |
(falls back to GEMINI_MODEL, default gemini-3.6-flash) |
Model ID sent in the request body (OpenRouter style: google/gemini-3.6-flash) |
Resolution happens once in Config::from_env() (config.rs):
- New struct field
llm_endpoint: String(envLLM_ENDPOINTwith the Gemini default above). LLM_API_KEY, when set, wins overGEMINI_API_KEYand is stored in the existinggemini_api_keyfield; likewiseLLM_MODELoverGEMINI_MODELintogemini_model. Reusing the existing fields keeps the struct, call sites, and ~6 test fixtures unchanged except for the one new field.- A code comment on both fields documents that they hold the resolved LLM credential/model, not necessarily a Gemini one; the field names stay for churn-avoidance, not semantics.
Validation (in the existing Config::validate() next to the gemini_model
non-empty check): llm_endpoint must be non-empty and start with http:// or
https://. Anything else fails startup with a clear message.
Debug output: llm_endpoint printed as-is (it is not a secret);
gemini_api_key stays [REDACTED].
Backward compatibility: a deployment that sets none of the new variables
behaves byte-for-byte as today (Gemini endpoint, GEMINI_API_KEY,
GEMINI_MODEL). The production .env needs no changes until the operator
opts into OpenRouter.
Call-site changes
- Delete the three per-file
GEMINI_ENDPOINTconsts; POST toconfig.llm_endpoint(viastate.configwhereAppStateis at hand, via the&Configparameter inalert_llm.rs). - Request bodies,
bearer_auth, timeouts (15 s alert path), response caps (256 KiB success / 8 KiB error), andreasoning_effort: "low"are unchanged — OpenRouter acceptsreasoning_effortand ignores or maps it per provider. - Log/error strings that say "Gemini" in these paths change to "LLM" only where the edit is a pure string rename; no structural log changes.
Runtime config registry
assistant.gemini_model keeps its key (rows are already seeded in
production system_config). Its registry description changes to "Model
override for the facility assistant, sent to the configured LLM endpoint".
It continues to override only the assistant's model, on whatever endpoint is
configured.
Testing
- Unit tests in
config.rsfor the resolution rules:LLM_API_KEYwins overGEMINI_API_KEY;LLM_MODELwins overGEMINI_MODEL; defaults apply when unset; invalidllm_endpointscheme fails validation. Follow the existing env-var test pattern inconfig.rs(serialized env mutation). - Existing integration-test fixtures gain the
llm_endpointfield with the Gemini default. cargo check+cargo clippyclean; full test suite passes.
Documentation
.env.exampleandscripts/setup-server.sh: commentedLLM_ENDPOINT/LLM_API_KEY/LLM_MODELblock with an OpenRouter example.docs/DEPLOYMENT-GUIDE.md: three new rows in the env table.docs/API.{en,ko}.md: daily-report and assistant sections note the model resolution order (LLM_MODEL→GEMINI_MODEL; assistant additionallyassistant.gemini_model).docs/KNOWLEDGE-BASE.{md,ko.md}: short entry describing the OpenRouter switch procedure.
Rollout
- Implement + tests + docs, one feature commit.
- Deploy via
scripts/deploy.sh; default behavior is unchanged, so the deploy is risk-free for the live Gemini path. - Switching production to OpenRouter later is an
.envedit (three variables) + container restart; switching back is deleting them.
Out of scope
- Runtime failover between providers.
- Per-feature provider/model selection beyond the existing assistant model override.
- Streaming responses (no call site streams today).