synapse-gateway
English · Español
synapse-gateway is an OpenAI-compatible LLM router and gateway written in Rust. It accepts standard OpenAI POST /v1/chat/completions requests and routes them through config-driven fallback chains to one of two backend lanes: a standard lane (via the genai crate, supporting OpenAI, Qwen/DashScope, and other OpenAI-compatible providers) or a native Vertex AI lane (using raw HTTP to the Vertex REST API with support for cached content, Cloud Storage media URIs, and strict response schemas). Prometheus metrics and OpenTelemetry gen_ai.* span attributes are emitted for every request, and a per-tenant cost ledger records token usage events to SQLite or Postgres.
Why yet another LLM router/gateway?
The honest answer: we tried not to write one. We evaluated litellm-rs (and the general "just put an OpenAI-compatible proxy in front of everything" approach) first — and it would have cost us the one thing we couldn't give up: native Vertex AI.
-
It doesn't flatten Vertex down to the lowest common denominator.
litellm-rsand most OpenAI-compatible gateways reach Vertex/Gemini only through a generic OpenAI-shaped adapter, which throws away the Vertex-specific features we actually depend on: context caching (cachedContent), Cloud Storage (gs://) media URIs, and strict nativeresponseSchemaconstrained decoding. synapse keeps a dedicated native Vertex lane that speaks:generateContent/:streamGenerateContentdirectly, so those capabilities survive — while everything else still rides the standard OpenAI-compatible lane viagenai. You get multi-provider routing and Vertex's native power, not one or the other. -
It's small and owned, not a framework. synapse is a single Rust binary — or an embeddable library crate (
default-features = false, callGateway::chat()in-process) — with a focused dependency set. Because we own the routing, fallback, ledger, and observability code, the thingsother gatewaysdidn't offer were straightforward to add rather than upstream battles: a per-tenant cost ledger with multi-sink fan-out (SQLite/Postgres + Pub/Sub + SNS), and OpenTelemetrygen_ai.*spans + Prometheus metrics on every request. -
The good bits are standard, not premium add-ons. Streaming is real and on by default: the gateway always streams from upstream internally, so
stream: trueclients get token-by-token OpenAI-compatible SSE, and non-streaming clients get that same response buffered into one JSON object — which means they keep full fallback across the whole chain. Tool / function calling works on both lanes. And because the surface is plain OpenAI-compatible, existing OpenAI SDKs work unchanged. None of this is gated behind a tier; it's the baseline.
In short: synapse is the simple OpenAI-compatible gateway that doesn't make you trade away Vertex's native capabilities to get streaming, tool calling, multi-provider fallback, and cost accounting.
Architecture: two lanes
Standard lane
Requests without a vertex extension block are handled by the standard lane, which uses the genai crate as its HTTP adapter. Any provider reachable via an OpenAI-compatible API (OpenAI, Qwen/DashScope, self-hosted vLLM/Ollama/TGI via oai_compat) can appear in a fallback chain.
Native Vertex lane
If the request body contains a vertex extension object with any of cached_content, media_uris, or response_schema, the request is routed to the native Vertex lane. This lane speaks directly to the Vertex AI generateContent REST endpoint, translating the OpenAI message format while preserving Vertex-specific features:
cached_content— acachedContentsresource name for context caching.media_uris—gs://Cloud Storage URIs attached as inline parts.response_schema— a JSON schema passed asgenerationConfig.responseSchemafor constrained decoding.
A route leg that is reachable only by the standard lane (i.e. has no vertex leg configured) will return 400 Bad Request if a native-Vertex request is sent against it.
Lane detection
The presence of the vertex key (any of its fields) is the sole signal. Requests without it always go to the standard lane.
Quick start
Prerequisites
Set the credentials for every provider referenced in your config/routes.toml:
# Vertex AI (Application Default Credentials are used via google-cloud-auth)
# Qwen / DashScope
# export DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # optional
# OpenAI
# export OPENAI_BASE_URL=https://api.openai.com/v1 # optional
# OAI-compatible self-hosted (vLLM / Ollama / TGI)
# export OAI_COMPAT_API_KEY=token-xyz # optional
Run
# Server: 0.0.0.0:8080
# Prometheus: 0.0.0.0:9090
Standard request
Streaming request
Responses are Server-Sent Events (SSE) in the standard OpenAI data: {...} format, terminated by data: [DONE].
Native Vertex request
Streaming & tool calling
Streaming
Setting "stream": true returns an OpenAI-compatible Server-Sent Events response: a sequence of chat.completion.chunk events (each prefixed data: ) terminated by data: [DONE]. Without it, a single chat.completion JSON object is returned.
Internally the gateway always streams from the upstream provider, even for non-streaming clients. Non-streaming responses are fully buffered before delivery, so the complete fallback chain (all legs) is available on any failure — including mid-stream failures on early legs.
Tool calling
Tool calling is supported on both lanes:
- Standard lane — send OpenAI
tools(array of{type: "function", function: {name, description, parameters}}) and optionallytool_choice. The gateway translates them for thegenaicrate. Note:tool_choiceis best-effort on this lane; genai 0.6ChatRequesthas notool_choicefield, so it is not forwarded. - Native Vertex lane —
toolsare translated to VertexfunctionDeclarations;tool_choiceis honored natively viatoolConfig.functionCallingConfig.
Responses carry tool_calls on the assistant message and finish_reason: "tool_calls". In streaming mode, tool-call deltas are emitted as indexed chat.completion.chunk events (same shape as the OpenAI streaming spec).
Timeouts
Two environment variables bound stream latency:
| Variable | Default | Description |
|---|---|---|
SYNAPSE_REQUEST_TIMEOUT_SECS |
120 |
Maximum time to the first chunk (time-to-first-token). A leg that does not produce its first chunk within this window is abandoned and the chain falls back to the next leg. |
SYNAPSE_STREAM_IDLE_TIMEOUT_SECS |
60 |
Maximum gap between successive chunks. If no chunk arrives within this window after streaming has started, the leg is terminated with a mid-stream error. |
Both timeouts apply to the standard lane. The native Vertex lane is currently bounded only by the underlying HTTP client timeout (SYNAPSE_REQUEST_TIMEOUT_SECS); idle timeout and first-chunk fallback for that lane are a tracked follow-up.
Endpoints
| Method | Path | Description |
|---|---|---|
GET |
/health |
Returns 200 OK with {"status":"ok"}. |
GET |
/v1/models |
Lists all model aliases defined in routes.toml. |
POST |
/v1/chat/completions |
OpenAI-compatible chat completions. Supports stream: true (SSE). Accepts optional vertex extension block. |
Configuration
Environment variables
| Variable | Default | Description |
|---|---|---|
SYNAPSE_ADDR |
0.0.0.0:8080 |
Address and port for the main HTTP server. |
SYNAPSE_METRICS_ADDR |
0.0.0.0:9090 |
Address and port for the Prometheus metrics endpoint. |
SYNAPSE_ROUTES_PATH |
config/routes.toml |
Path to the route configuration file. |
SYNAPSE_PRICING_PATH |
config/pricing.toml |
Path to the pricing configuration file. |
SYNAPSE_LEDGER_BACKENDS |
sqlite |
Comma-separated list of active ledger sinks (e.g. postgres,pubsub). Every event fans out to all listed sinks. |
SYNAPSE_LEDGER_BACKEND |
— | Single-backend alias; used when SYNAPSE_LEDGER_BACKENDS is not set. |
SYNAPSE_LEDGER_SQLITE_DSN |
sqlite://synapse.db?mode=rwc |
SQLite DSN. Falls back to SYNAPSE_LEDGER_DSN, then the default path. |
SYNAPSE_LEDGER_POSTGRES_DSN |
— | Postgres DSN. Falls back to SYNAPSE_LEDGER_DSN. Required when postgres is in the backend list. |
SYNAPSE_LEDGER_DSN |
sqlite://synapse.db?mode=rwc |
Legacy single-backend DSN (SQLite or Postgres). Prefer the per-backend vars above. |
SYNAPSE_LEDGER_PUBSUB_TOPIC |
— | Pub/Sub topic ID. Required when pubsub is in the backend list (ledger-pubsub feature). |
SYNAPSE_LEDGER_PUBSUB_PROJECT |
— | GCP project for Pub/Sub. Falls back to VERTEX_PROJECT. |
SYNAPSE_LEDGER_SNS_TOPIC_ARN |
— | SNS topic ARN. Required when sns is in the backend list (ledger-sns feature). |
SYNAPSE_LEDGER_SNS_REGION |
— | AWS region for SNS. Optional; the AWS default credential chain is used if absent. |
SYNAPSE_DEFAULT_TENANT |
unattributed |
Tenant name used when x-synapse-tenant header is absent. |
SYNAPSE_REQUEST_TIMEOUT_SECS |
120 |
Time-to-first-chunk timeout in seconds. A leg that does not produce its first chunk within this window falls back to the next leg. |
SYNAPSE_STREAM_IDLE_TIMEOUT_SECS |
60 |
Maximum inter-chunk idle gap in seconds. A leg that stalls mid-stream for this long is terminated. |
Provider credential variables
The gateway performs a fail-fast credential check at startup. If a provider is referenced in routes.toml but its required credentials are missing, the process exits immediately.
| Provider | Required | Optional |
|---|---|---|
vertex |
VERTEX_PROJECT (ADC via google-cloud-auth) |
— |
qwen |
DASHSCOPE_API_KEY |
DASHSCOPE_BASE_URL |
openai |
OPENAI_API_KEY |
OPENAI_BASE_URL |
oai_compat |
OAI_COMPAT_BASE_URL |
OAI_COMPAT_API_KEY |
config/routes.toml
Maps a client-facing model alias to an ordered list of fallback legs. The gateway tries each leg in order, advancing on error.
[]
= [
{ = "vertex", = "gemini-3-pro" },
{ = "qwen", = "qwen-max" },
]
[]
= [{ = "vertex", = "gemini-3-flash" }]
config/pricing.toml
Maps provider:model to input/output cost in USD per 1,000,000 tokens. Models not listed cost 0.
# USD per 1,000,000 tokens. Open-source/self-hosted default to 0.
[]
= 1.25
= 5.0
[]
= 0.30
= 1.20
[]
= 1.6
= 6.4
Tenant attribution
Two request headers control cost and observability attribution:
| Header | Description |
|---|---|
x-synapse-tenant |
Tenant identifier. Falls back to SYNAPSE_DEFAULT_TENANT (unattributed). |
x-synapse-workspace |
Optional sub-grouping within a tenant (e.g. a project or team). |
Both values are recorded on ledger usage_events rows and carried as attributes on gen_ai.* spans.
Observability
Prometheus
Metrics are served at SYNAPSE_METRICS_ADDR (default :9090).
| Metric | Type | Labels | Description |
|---|---|---|---|
synapse_requests_total |
Counter | route, model, system, lane |
Total requests served. |
synapse_request_duration_seconds |
Histogram | route, model, system, lane |
End-to-end request latency. |
synapse_input_tokens_total |
Counter | route, model, system, lane |
Cumulative input tokens consumed. |
synapse_output_tokens_total |
Counter | route, model, system, lane |
Cumulative output tokens generated. |
synapse_ledger_dropped_total |
Counter | — | Ledger events dropped due to a full channel (fire-and-forget overflow). |
synapse_ledger_errors_total |
Counter | backend |
Per-sink write failures (e.g. backend="pubsub"). One sink failing does not stop the others. |
All four synapse_* token/request metrics share the same label set:
route— the client-facing model alias (e.g.gemini-pro,fast).model— the model that actually served the request (as returned by the backend leg).system— the OpenLLMetrygen_ai.systemvalue:vertexai,openai,dashscope, oroai_compat.lane—standard(genai crate) ornative(direct Vertex REST).
Tenant and workspace are not Prometheus labels. They are recorded in the cost ledger (usage_events table) and carried as attributes on gen_ai.* tracing spans. Keeping them out of metric labels avoids unbounded cardinality from untrusted client-supplied header values.
Tracing
Structured spans follow the OpenTelemetry gen_ai.* semantic conventions (model, provider, token counts, error kinds). Configure the log level and format via RUST_LOG (e.g. RUST_LOG=info).
Cost ledger
Token usage is recorded asynchronously to a usage_events table after every successful completion. The ledger write is fire-and-forget: if the internal channel is full, the event is dropped and synapse_ledger_dropped_total is incremented — request latency is never affected.
Multi-sink fan-out
Multiple backends can run simultaneously. Every usage event is delivered to every configured sink concurrently. One sink failing never blocks the others; per-sink failures are logged and counted on synapse_ledger_errors_total{backend=<name>}.
Select backends with SYNAPSE_LEDGER_BACKENDS (comma-separated). The singular SYNAPSE_LEDGER_BACKEND is still accepted as a one-item fallback. When neither variable is set the default is sqlite.
# Fan-out to both Postgres and Pub/Sub
SYNAPSE_LEDGER_BACKENDS=postgres,pubsub
Backends
| Backend | Cargo feature | Env vars | Notes |
|---|---|---|---|
| SQLite | ledger-sqlite (default) |
SYNAPSE_LEDGER_SQLITE_DSN (fallback: SYNAPSE_LEDGER_DSN, then sqlite://synapse.db?mode=rwc) |
File created automatically. |
| Postgres | ledger-postgres |
SYNAPSE_LEDGER_POSTGRES_DSN (fallback: SYNAPSE_LEDGER_DSN) |
Requires a connection string. |
| GCP Pub/Sub | ledger-pubsub |
SYNAPSE_LEDGER_PUBSUB_TOPIC (required), SYNAPSE_LEDGER_PUBSUB_PROJECT (fallback: VERTEX_PROJECT) |
ADC auth; ordering key is requestId. |
| AWS SNS | ledger-sns |
SYNAPSE_LEDGER_SNS_TOPIC_ARN (required), SYNAPSE_LEDGER_SNS_REGION (optional, else AWS default chain) |
Standard AWS credential chain. |
SQLite is enabled by default. The cloud backends (ledger-pubsub, ledger-sns) are feature-gated and pull no cloud SDK unless explicitly enabled.
# Build with Pub/Sub support
# Build with SNS support
# Build with both cloud backends
Published event format (Pub/Sub and SNS)
Both cloud backends publish a talos-aligned JSON payload (camelCase; tenant as namespace; type: "usage"):
Every message carries attributes for subscription filtering: namespace, requestId, type, provider, status. Pub/Sub additionally sets requestId as the message ordering key.
Schema
The single migration (migrations/0001_usage_events.sql) creates the usage_events table with columns for tenant, workspace, provider, model, input tokens, output tokens, cost, and timestamp.
Building
Cargo
# Default build (SQLite ledger)
# Postgres ledger only
# SQLite + Pub/Sub fan-out
# SQLite + SNS fan-out
# All four backends
The release binary is at target/release/synapse-gateway.
Docker
The multi-stage Dockerfile uses rust:1-bookworm to compile and debian:bookworm-slim as the runtime image. config/ and migrations/ are copied into the image so it is self-contained; mount a volume over /app/config to supply your own route/pricing files at runtime.
Testing
# Run all tests (SQLite feature, default)
# Run all tests with all features (SQLite + Postgres)
The test suite (66 tests) covers route resolution, fallback behaviour, lane detection, tenant attribution, config parsing, ledger writes, HTTP handler integration, streaming primitives, tool-call accumulation, first-chunk timeout fallback, and SSE serialisation.
Limitations / roadmap
The following are not present in v1 and are planned for future releases:
- Authentication / API key enforcement on inbound requests.
- Rate limiting.
- Multi-region Vertex endpoint routing.
- Admin API for dynamic route reloading.
Contributing
Contributions are welcome. See CONTRIBUTING.md for how to build, test, and submit changes. Commits must be signed off under the Developer Certificate of Origin (git commit -s); contributions are licensed under AGPL-3.0. Please also read our Code of Conduct.
Security
Found a vulnerability? Do not open a public issue. See SECURITY.md for private disclosure (email raj@sustentabilitas.com or a GitHub private advisory).
License
Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See LICENSE.