saros-proxy
v0.7.4
Published
Saros — predictive multi-account proxy for the OpenCode-Go API. Cycles between accounts before they exhaust.
Maintainers
Readme
Saros
Saros — like the eclipse cycle, this proxy predicts exhaustion and rotates accounts before they run out. Built for the OpenCode-Go API.
Manage multiple OpenCode-Go API keys behind a single endpoint. When one key gets rate-limited, revoked, or returns server errors, the proxy automatically fails over to the next healthy key — zero downtime.
Contents
- Features
- Architecture
- Installation
- Configuration Reference
- Usage Examples
- OpenCode Integration
- Multi-Harness Integration (pi & oh-my-pi)
- Troubleshooting
- Development
- Security
- License
Features
- Multi-key rotation — Round-robin across all your API keys.
- Auto failover — Retry failed requests with the next healthy key.
- Circuit breaker — Disable flaky keys after a configurable threshold, then lazily re-enable them after cooldown.
- Streaming support — SSE passthrough with mid-stream error detection.
- Request timeout — Configurable timeout with a clean 504 response.
- Graceful shutdown — Drains active requests before exiting (no dropped connections).
- Security hardening — API key masking in logs, SSRF prevention (HTTPS-only upstream), configurable CORS, host binding validation.
- Config validation — CLI args, YAML file, or environment variables with sensible defaults.
- Health endpoint —
GET /healthshows key status, active requests, and circuit-breaker state. - No database — Stateless proxy, zero external dependencies beyond Node.js.
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Your App / OpenCode │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Request 1│ │ Request 2│ │ Request 3│ ... │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└────────┼──────────────┼──────────────┼──────────────────────┘
│ │ │
└──────────────┼──────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Saros (:3000) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Key Selector │─▶│ Circuit │─▶│ Request │ │
│ │ Round-Robin │ │ Breaker │ │ Forwarder │ │
│ │ + Failover │ │ Threshold + │ │ + Timeout + │ │
│ │ │ │ Cooldown │ │ SSE │ │
│ └──────────────┘ └──────────────┘ └──────┬───────┘ │
└─────────────────────────────────────────────┼───────────────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Key A │ │ Key B │ │ Key C │
│ sk-abc… │ │ sk-def… │ │ sk-ghi… │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└────────────┼────────────┘
▼
┌──────────────────────────┐
│ OpenCode-Go API │
│ https://opencode.ai │
└──────────────────────────┘How Request Distribution Works
Here's how Saros picks which API key to use — it runs on two levels:
Layer 1: Round-Robin (always on)
Every new request cycles to the next key in order:
Request 1 → Key A
Request 2 → Key B
Request 3 → Key C
Request 4 → Key A (wraps around)Even distribution by default. Got 2 accounts? Odd requests go to account 1, even to account 2. Simple.
Layer 2: Usage-Based Gating (optional)
If you enable dashboard scraping, Saros checks each account's quota before picking a key:
- Usage ≥ threshold (default 70%) → that key gets skipped
- All keys over threshold → falls back to the lowest-usage key
- No usage data available → pure round-robin, no change
| Scenario | Behavior | |---|---| | Scraping off | Pure round-robin across all keys | | Scraping on + data available | Round-robin, skipping over-quota accounts | | Scraping on + no data | Falls back to pure round-robin |
Concurrent Request Safety
Saros tracks which keys are currently in use. A key handling a streaming request won't get assigned another one until it finishes. No double-booking.
Installation
Prompt Install(for the lazy ones)
Don't feel like reading through everything? Copy this prompt and paste it to your AI assistant. It'll read LLM_INSTRUCTIONS.md and guide you through setup — no keys shared in chat.
Install Saros Proxy for my opencode. (https://github.com/vitorvilaca3011/saros-proxy)
Follow the instructions on LLM_INSTRUCTIONS.md.Quick Install (npm)
npm install -g saros-proxyFrom Source
git clone https://github.com/vitorvilaca3011/saros-proxy.git
cd proxy
npm install
npm run buildRequirements: Node.js >= 22.0.0
Docker
Run in a clean Linux container without installing Node.js locally.
Prerequisites
- Docker (Compose v2)
Quick start
# 1. Create config.yaml from the example (edit with your keys)
cp config.example.yaml config.yaml
# 2. Build the image
npm run docker:build
# 3. Start the proxy
npm run docker:runThe proxy is now at http://127.0.0.1:3000.
Run tests in Docker
npm run docker:testManual Docker commands
# Build
docker compose build proxy
# Run in foreground
docker compose up proxy
# Run in background (detached)
docker compose up -d proxy
# View logs
docker compose logs -f proxy
# Stop
docker compose down
# Run tests
docker compose run --rm testConfiguration with Docker
Mount a custom config.yaml or use environment variables:
# Using env vars (no config.yaml needed)
docker compose run --rm -e OPENCODE_GO_KEYS="home:sk-xxx,work:sk-yyy" proxy
# Or edit docker-compose.yml to set env vars, then:
docker compose up proxySetup Wizard
The easiest way to get going:
# If installed globally
saros-proxy setup
# Or with npx
npx saros-proxy setupThe wizard will:
- Ask for your proxy port (default: 3000)
- Ask for your API keys (name + key)
- Generate
config.yamlautomatically - Run a smoke test to verify everything works
- Configure opencode.json with provider settings and model definitions
Want to run manually instead? Skip the wizard and just create a config.yaml:
port: 3000
host: 127.0.0.1
upstreamBaseUrl: https://opencode.ai
keys:
- label: primary
key: sk-your-primary-key-here-12345678
- label: secondary
key: sk-your-secondary-key-here-87654321
circuitBreakerThreshold: 3
circuitBreakerCooldownMs: 60000
requestTimeoutMs: 30000Then run:
# Development (hot reload)
npx tsx src/index.ts
# Production (built)
node dist/index.jsTest that it's alive:
curl http://127.0.0.1:3000/health
# Query models
curl http://127.0.0.1:3000/zen/go/v1/models
# Chat completion
curl http://127.0.0.1:3000/zen/go/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4","messages":[{"role":"user","content":"Hello"}]}'Configuration Reference
Every option can be set via YAML (config.yaml), environment variables, or CLI flags. Priority: CLI > YAML > env vars > defaults.
Options
| Option | CLI Flag | Env Variable | Default | Notes |
|---|---|---|---|---|
| port | --port | PROXY_PORT | 3000 | 1–65535 |
| host | --host | PROXY_HOST | 127.0.0.1 | Bind address |
| upstreamBaseUrl | — | UPSTREAM_BASE_URL | https://opencode.ai | HTTPS only |
| circuitBreakerThreshold | — | CIRCUIT_BREAKER_THRESHOLD | 3 | Consecutive fails before disabling (1–10) |
| circuitBreakerCooldownMs | — | CIRCUIT_BREAKER_COOLDOWN_MS | 60000 | Cooldown before re-enabling (1000–3600000) |
| requestTimeoutMs | — | REQUEST_TIMEOUT_MS | 30000 | Upstream timeout (1000–300000) |
| allowedOrigins | — | — | ["http://localhost:*", "http://127.0.0.1:*"] | CORS origins ([] = allow all) |
| keys | — | OPENCODE_GO_KEYS | — | Array of {label, key} |
| config | --config | — | config.yaml | Path to YAML config |
API Key Format
Keys must start with sk- and be at least 20 characters.
YAML:
keys:
- label: my-account
key: sk-your-key-here...Environment variable:
OPENCODE_GO_KEYS="account1:sk-xxx,account2:sk-yyy"API Key Encryption
Keys can be encrypted at rest using AES-256-GCM with scrypt key derivation. Encrypted keys look like enc:scrypt:... in config.yaml and get decrypted at startup using the OPENCODE_GO_ENCRYPTION_KEY environment variable.
Setup with encryption:
npm run setup
# Choose to encrypt keys and enter a master key (min 16 chars)Running with encrypted keys:
export OPENCODE_GO_ENCRYPTION_KEY="your-master-key"
npx tsx src/index.tsDocker with encrypted keys:
# docker-compose.yml
environment:
- OPENCODE_GO_ENCRYPTION_KEY=your-master-keyNotes:
- Encrypted keys use AES-256-GCM with scrypt KDF (N=16384, r=8, p=1)
- Each key has its own random salt and IV
- Plaintext keys (
sk-...) still work — backward compatible OPENCODE_GO_KEYSenv var is always plaintext (for CI/CD)- If keys are encrypted but no
OPENCODE_GO_ENCRYPTION_KEYis set, startup fails with a clear error
CLI Flags
tsx src/index.ts --config my-config.yaml --port 4000 --host 0.0.0.0Usage Examples
Basic: YAML config
# Create config.yaml, then:
npx tsx src/index.tsAdvanced: Environment variables only
export PROXY_PORT=4000
export PROXY_HOST=127.0.0.1
export OPENCODE_GO_KEYS="home:sk-abc123...,work:sk-def456..."
export CIRCUIT_BREAKER_THRESHOLD=5
export CIRCUIT_BREAKER_COOLDOWN_MS=120000
export REQUEST_TIMEOUT_MS=45000
export LOG_LEVEL=debug
export NODE_ENV=development
npx tsx src/index.tsCustom config path
npx tsx src/index.ts --config /etc/saros/config.yamlRun as background daemon (all platforms)
The proxy ships with a built-in daemon. Once installed globally (npm install -g saros-proxy):
# Show the overview (running state, harnesses, API keys, config path)
saros-proxy
# Start the daemon (background)
saros-proxy start --port 3000
# Check status
saros-proxy status
# Stop it
saros-proxy stop
# Sync bundled models to all enabled harnesses
saros-proxy sync-models
# Add harnesses to the model-sync selection (omp|ohmypi, pi, oc|opencode)
saros-proxy configharness omp pi oc
# Remove a harness, or disable sync entirely
saros-proxy configharness --remove pi
saros-proxy configharness --clear
# List the current harness selection
saros-proxy configharness
# Probe model capabilities (liveness, reasoning, tool calling)
saros-proxy probe [model-id]
# Probe all models
saros-proxy probe
# Install autostart (Windows: VBS script or Registry)
saros-proxy autostart install [--port <port>] [--method vbs|registry|auto]
# Uninstall autostart
saros-proxy autostart uninstall [--method vbs|registry|auto]
# Check autostart status
saros-proxy autostart status [--method vbs|registry|auto]The start command automatically syncs model definitions to all enabled harnesses (see Multi-Harness Integration).
Model Sync Commands
Saros keeps your harness configs in sync with the upstream API. Which harnesses are synced is opt-in via the harness selection (see Multi-Harness Integration).
# List which harnesses are enabled for model sync
saros-proxy configharness
# Add oh-my-pi, pi, and OpenCode to the sync selection (additive)
saros-proxy configharness omp pi oc
# Remove a harness, or disable sync entirely
saros-proxy configharness --remove pi
saros-proxy configharness --clear
# Sync bundled models to all enabled harnesses
saros-proxy sync-models
# Sync the live model list from upstream into all enabled harnesses
saros-proxy sync-upstream
# Probe model capabilities
saros-proxy probe [model-id]
# Probe all configured models
saros-proxy probesync-models writes the bundled model definitions to each enabled harness's provider config.
sync-upstream fetches the live model list from upstream and writes it to each enabled harness, with metadata from models.dev.
Both only touch providers["saros-proxy"].models in the target configs and skip harnesses whose config file does not exist.
probe tests each model's liveness, reasoning, and tool-calling capabilities via the proxy.
Daemon config path: By default the daemon looks for config.yaml at:
- Linux/macOS:
~/.config/saros/config.yaml - Windows:
%LOCALAPPDATA%\saros\config.yaml
Override with --config:
saros-proxy start --port 3000 --config /path/to/config.yamlThe PID file is stored at ~/.config/saros/daemon.pid. The child process runs with NODE_ENV=production (JSON logging), and its stderr is appended to ~/.config/saros/daemon.log.
saros-proxy status checks the PID file first, then falls back to an os-agnostic GET /health port probe — so it reports the truth even when the proxy was started outside the daemon (manually, via autostart, or in another session) and no PID file exists. It also prints the enabled harnesses (configharness selection). saros-proxy start refuses to start a second instance when the configured port is already serving.
Bare saros-proxy (no subcommand) prints the same overview without starting anything: running state, harnesses, masked API keys, and config path. To run the proxy in the foreground (e.g. for development or in a container), use saros-proxy serve [--port <port>] [--config <path>] — the daemon child and the Docker entrypoint run serve under the hood.
OpenCode Integration
Configure OpenCode to route API requests through the proxy. Edit your OpenCode config file at ~/.config/opencode/opencode.json (global) or opencode.json in your project root:
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"saros-proxy": {
"npm": "@ai-sdk/openai-compatible",
"name": "Saros",
"options": {
"baseURL": "http://127.0.0.1:3000/zen/go/v1",
"apiKey": "not-used"
},
"models": {
"glm-5": { "name": "GLM-5" },
"kimi-k2.5": { "name": "Kimi K2.5" },
"qwen3.7-plus": { "name": "Qwen 3.7 Plus" }
}
}
}
}Key points:
baseURLmust end at/v1— OpenCode appends route paths internallyapiKeycan be anything — the proxy handles real key selection- Model IDs must match what upstream expects (e.g.,
glm-5, notopencode-go/glm-5) - Restart OpenCode after editing the config
Alternative: Environment variable substitution
{
"provider": {
"saros-proxy": {
"npm": "@ai-sdk/openai-compatible",
"name": "Saros",
"options": {
"baseURL": "{env:OPENCODE_PROXY_URL}",
"apiKey": "{env:OPENCODE_PROXY_KEY}"
}
}
}
}Then set: export OPENCODE_PROXY_URL=http://127.0.0.1:3000/zen/go/v1
Using the proxy in OpenCode:
Select the proxy provider in OpenCode's model picker, or set it as default:
{
"provider": { /* ... as above ... */ },
"model": "saros-proxy/glm-5"
}Multi-Harness Integration (pi & oh-my-pi)
Besides OpenCode, Saros can keep its provider + model config in sync for the pi and oh-my-pi (omp) harnesses. Both belong to the pi-coding-agent family but use different config files:
| Harness | Config file | Format |
|---|---|---|
| OpenCode | ~/.config/opencode/opencode.json[.jsonc] | JSON/JSONC |
| pi | ~/.pi/agent/models.json | JSON |
| oh-my-pi (omp) | ~/.omp/agent/models.yml | YAML |
Sync is opt-in per harness, configured with the configharness command. The selection is stored in ~/.config/saros/harnesses.json:
# Add omp + pi + opencode to the sync selection (additive)
saros-proxy configharness omp pi oc
# Remove a harness, or disable sync entirely
saros-proxy configharness --remove pi
saros-proxy configharness --clear
# See the current selection
saros-proxy configharness
# Accepted names: omp | ohmypi, pi, oc | opencodeconfigharness is additive: each command adds the named harnesses to the current selection (running configharness omp then configharness pi enables both). Use --remove <h>... to disable specific harnesses or --clear to disable sync for every harness.
Behavior:
- If
harnesses.jsondoes not exist, only OpenCode is synced (the default). - An explicit empty selection (
{"harnesses":[]}) disables sync entirely. - Sync never creates a harness config: harnesses whose config file is missing are skipped.
- Only
providers["saros-proxy"].modelsis replaced; all other providers, fields, and settings are preserved, and a<file>.backupis created before the first write.
The model list written to each harness comes from the live upstream catalog enriched with models.dev metadata (falling back to bundled models when offline), so pi/omp stay current alongside OpenCode.
Why a static model list? Both harnesses use a static
modelslist on purpose.pihas no/v1/modelsdiscovery for custom providers, andoh-my-pi's discovery cannot recover reasoning/context/output metadata for OpenCode-Go model IDs — dynamic discovery would silently break thinking-level (:max/:high) role configs in~/.omp/agent/config.yml.
Troubleshooting
Check proxy health
curl http://127.0.0.1:3000/healthReturns JSON with key counts, active requests, and circuit-breaker state.
View logs
# Dev mode (pretty-printed, colorized)
NODE_ENV=development npx tsx src/index.ts
# Production (structured JSON)
NODE_ENV=production npx tsx src/index.tsSee which key served a request
Saros adds two debug headers to every response:
| Header | Example | What it is |
|---|---|---|
| X-Proxy-Key-Label | primary | Which key was used |
| X-Proxy-Request-Id | 550e8400-e29b-41d4-a716-446655440000 | Unique ID for log correlation |
curl -s -D - http://127.0.0.1:3000/zen/go/v1/models | grep -i x-proxy
# X-Proxy-Key-Label: primary
# X-Proxy-Request-Id: 550e8400-e29b-41d4-a716-446655440000Common issues
| Symptom | Likely cause | Fix |
|---|---|---|
| No valid API keys configured | Missing or bad keys | Check keys in config.yaml or OPENCODE_GO_KEYS |
| ECONNREFUSED on startup | Port already in use | Change port or kill the existing process |
| 504 Gateway Timeout | Upstream too slow | Increase requestTimeoutMs |
| All API keys are temporarily unavailable | All keys disabled by circuit breaker | Wait for cooldown or check upstream status |
| CORS error | Client origin not allowed | Add it to allowedOrigins in config |
Development
Run tests
# All tests
npm test
# Just E2E (needs network)
npx vitest run test/e2e.test.ts
# Watch mode
npm test -- --watchType check
npm run lintBuild
npm run buildOutput goes to dist/.
Project structure
src/
index.ts — Entry point, CLI dispatch, server startup
proxy.ts — Hono app, routing, streaming, failover
proxy-logic.ts — Pure logic: key selection, circuit breaker
config.ts — Config loading from YAML, env vars, CLI
constants.ts — All defaults and configuration values
logger.ts — Structured logging with Pino + key masking
models-fetcher.ts — Upstream model list fetching + caching
models-sync.ts — Auto-sync models from upstream to opencode config
cli/
setup.ts — Interactive setup wizard
daemon.ts — Background process management
opencode-config.ts — opencode.json read/write/sync
harness-models.ts — Canonical model map + pi/omp shape transform
harness-sync.ts — Harness selection settings + multi-harness sync
omp-config.ts — oh-my-pi models.yml read/write/sync
pi-config.ts — pi models.json read/write/sync
autostart.ts — Windows/Linux autostart install/uninstall
ui.ts — CLI UI abstraction (@clack/prompts wrapper)
help.ts — Help text
update-check.ts — Version update check
test/
e2e.test.ts — End-to-end tests with mock upstream
cli-setup.test.ts — CLI setup wizard testsContributing
- Fork the repo.
- Create a feature branch:
git checkout -b feat/my-feature - Commit:
git commit -am 'Add my feature' - Push:
git push origin feat/my-feature - Open a pull request.
Security
For vulnerability reporting, see SECURITY.md.
License
MIT © 2026 Saros Contributors
