saros-proxy 0.10.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +261 -78
- package/dist/cli/daemon.d.ts +38 -6
- package/dist/cli/daemon.js +524 -109
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/dsh-config.js +34 -9
- package/dist/cli/dsh-config.js.map +1 -1
- package/dist/cli/help.js +3 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/key-state.d.ts +21 -0
- package/dist/cli/key-state.js +57 -0
- package/dist/cli/key-state.js.map +1 -0
- package/dist/cli/keys.d.ts +28 -0
- package/dist/cli/keys.js +185 -0
- package/dist/cli/keys.js.map +1 -0
- package/dist/cli/overview.js +6 -1
- package/dist/cli/overview.js.map +1 -1
- package/dist/cli/secret.d.ts +31 -0
- package/dist/cli/secret.js +89 -0
- package/dist/cli/secret.js.map +1 -0
- package/dist/cli/usage.js +20 -10
- package/dist/cli/usage.js.map +1 -1
- package/dist/config.d.ts +15 -1
- package/dist/config.js +98 -25
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/constants.js.map +1 -1
- package/dist/debug-buffer.d.ts +58 -0
- package/dist/debug-buffer.js +42 -0
- package/dist/debug-buffer.js.map +1 -0
- package/dist/index.js +30 -4
- package/dist/index.js.map +1 -1
- package/dist/log-redact.d.ts +18 -0
- package/dist/log-redact.js +41 -0
- package/dist/log-redact.js.map +1 -0
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +30 -0
- package/dist/logger.js.map +1 -1
- package/dist/models-fetcher.js +2 -0
- package/dist/models-fetcher.js.map +1 -1
- package/dist/providers/commandcode.d.ts +20 -5
- package/dist/providers/commandcode.js +115 -7
- package/dist/providers/commandcode.js.map +1 -1
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.js +2 -2
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/infer.d.ts +11 -0
- package/dist/providers/infer.js.map +1 -1
- package/dist/providers/opencode-go.d.ts +8 -0
- package/dist/providers/opencode-go.js +35 -7
- package/dist/providers/opencode-go.js.map +1 -1
- package/dist/providers/types.d.ts +10 -2
- package/dist/proxy-logic.d.ts +101 -7
- package/dist/proxy-logic.js +197 -41
- package/dist/proxy-logic.js.map +1 -1
- package/dist/proxy.d.ts +33 -1
- package/dist/proxy.js +369 -67
- package/dist/proxy.js.map +1 -1
- package/dist/secrets/boot.d.ts +33 -0
- package/dist/secrets/boot.js +65 -0
- package/dist/secrets/boot.js.map +1 -0
- package/dist/secrets/envelope.d.ts +44 -0
- package/dist/secrets/envelope.js +132 -0
- package/dist/secrets/envelope.js.map +1 -0
- package/dist/secrets/source.d.ts +130 -0
- package/dist/secrets/source.js +332 -0
- package/dist/secrets/source.js.map +1 -0
- package/dist/token-usage.d.ts +9 -2
- package/dist/token-usage.js +20 -4
- package/dist/token-usage.js.map +1 -1
- package/dist/usage-client.d.ts +43 -13
- package/dist/usage-client.js +37 -54
- package/dist/usage-client.js.map +1 -1
- package/dist/validation.d.ts +3 -3
- package/dist/validation.js +3 -3
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,51 +44,55 @@ Manage multiple API keys behind a single endpoint — OpenCode-Go **and** Comman
|
|
|
44
44
|
- **Graceful shutdown** — Drains active requests before exiting (no dropped connections).
|
|
45
45
|
- **Security hardening** — API key masking in logs, SSRF prevention (HTTPS-only upstream), configurable CORS, host binding validation.
|
|
46
46
|
- **Config validation** — CLI args, YAML file, or environment variables with sensible defaults.
|
|
47
|
-
- **Health endpoint** — `GET /health` shows key status, active requests, and circuit-breaker state.
|
|
47
|
+
- **Health endpoint** — `GET /health` shows key status per key (enabled, why it is out of rotation, when it retries), active requests, and circuit-breaker state.
|
|
48
|
+
- **Request forensics** — `X-Proxy-Provider` / `X-Proxy-Key-Label` / `X-Proxy-Request-Id` headers on served responses, plus `GET /debug/recent` (last 200 requests in memory, no credentials).
|
|
48
49
|
- **No database** — Stateless proxy, zero external dependencies beyond Node.js.
|
|
49
50
|
|
|
50
51
|
---
|
|
51
52
|
|
|
52
53
|
## Architecture
|
|
53
54
|
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
55
|
+
```mermaid
|
|
56
|
+
flowchart TB
|
|
57
|
+
subgraph client["Your App / OpenCode"]
|
|
58
|
+
direction LR
|
|
59
|
+
r1["Request 1"]
|
|
60
|
+
r2["Request 2"]
|
|
61
|
+
r3["Request 3"]
|
|
62
|
+
rest["..."]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
subgraph saros["Saros :3000"]
|
|
66
|
+
direction LR
|
|
67
|
+
ks["Key Selector<br/>round-robin + failover"]
|
|
68
|
+
cb["Circuit Breaker<br/>threshold + cooldown"]
|
|
69
|
+
fw["Request Forwarder<br/>timeout + SSE"]
|
|
70
|
+
ks --> cb --> fw
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
subgraph keys["API keys"]
|
|
74
|
+
direction LR
|
|
75
|
+
ka["Key A<br/>sk-abc…"]
|
|
76
|
+
kb["Key B<br/>sk-def…"]
|
|
77
|
+
kc["Key C<br/>user_…"]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
oc["OpenCode-Go API<br/>opencode.ai"]
|
|
81
|
+
cc["CommandCode API<br/>api.commandcode.ai"]
|
|
82
|
+
|
|
83
|
+
r1 --> ks
|
|
84
|
+
r2 --> ks
|
|
85
|
+
r3 --> ks
|
|
86
|
+
rest --> ks
|
|
87
|
+
fw --> ka
|
|
88
|
+
fw --> kb
|
|
89
|
+
fw --> kc
|
|
90
|
+
ka --> oc
|
|
91
|
+
kb --> oc
|
|
92
|
+
kc --> oc
|
|
93
|
+
ka --> cc
|
|
94
|
+
kb --> cc
|
|
95
|
+
kc --> cc
|
|
92
96
|
```
|
|
93
97
|
|
|
94
98
|
### How Request Distribution Works
|
|
@@ -97,13 +101,46 @@ Here's how Saros picks which API key to use:
|
|
|
97
101
|
|
|
98
102
|
#### Usage-Weighted Rotation
|
|
99
103
|
|
|
100
|
-
Saros polls
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
Saros polls each key's quota endpoint and weights rotation by **remaining
|
|
105
|
+
monthly** quota. If key X is 30% into its monthly allowance and key Y is 70%,
|
|
106
|
+
10 requests are distributed 7 → X and 3 → Y — proportional balancing, not hard
|
|
107
|
+
cutoffs, so neither key absorbs the full load when the other runs low.
|
|
108
|
+
|
|
109
|
+
The number Saros compares is always a percentage of that key's **own provider
|
|
110
|
+
monthly budget**, never absolute capacity:
|
|
111
|
+
|
|
112
|
+
| provider | endpoint | what it reports |
|
|
113
|
+
| --- | --- | --- |
|
|
114
|
+
| OpenCode Go | `GET /zen/go/v1/usage` | `rolling` (5h) / `weekly` / `monthly` as percents |
|
|
115
|
+
| CommandCode | `GET /alpha/billing/credits` | `fiveHour` and `weekly` as `used`/`cap` in credits, `credits.monthlyCredits` (remaining) and reset timestamps |
|
|
116
|
+
|
|
117
|
+
CommandCode's payload has no explicit monthly cap. The parser **infers** it
|
|
118
|
+
from the window caps (the 20% / 50% / 100% ladder is OpenCode Go's documented
|
|
119
|
+
plan shape; for CommandCode the 14/35 ratio was measured against its
|
|
120
|
+
dashboard): monthly cap = 2 × `weekly.cap`, falling back to 5 ×
|
|
121
|
+
`fiveHour.cap` when the weekly window is absent. The candidate cap is only
|
|
122
|
+
accepted when the credits agree with it — `credits.monthlyCredits <= cap` and
|
|
123
|
+
`cap − monthlyCredits >= max(weekly.used, fiveHour.used)`; otherwise the
|
|
124
|
+
fallback is tried and, if it also disagrees, the response is treated as
|
|
125
|
+
unusable (the previous usage state is kept rather than branding a broken
|
|
126
|
+
derivation as 0% usage). A payload without `monthlyCredits` reports the
|
|
127
|
+
monthly metric as unavailable (`n/a` in `saros-proxy usage`). Both parsers
|
|
128
|
+
emit the same 0-100 percent scale, so a mixed pool compares like with like.
|
|
129
|
+
Two consequences worth knowing:
|
|
130
|
+
|
|
131
|
+
- **Monthly is the weight; a spent window still excludes the key.** A key with
|
|
132
|
+
50% of the month left but a 5-hour or weekly window at 100% weighs 0 and is
|
|
133
|
+
not selected while another key has room — otherwise it would keep receiving
|
|
134
|
+
traffic and failing upstream. `/health`'s `usagePercent` is the monthly
|
|
135
|
+
number.
|
|
136
|
+
- **Percent is all there is.** OpenCode Go publishes percents only — no counts,
|
|
137
|
+
no caps — so "remaining capacity" in tokens or dollars cannot be derived.
|
|
104
138
|
|
|
105
139
|
- Refreshed in the background every 60s (stale data keeps working).
|
|
106
|
-
-
|
|
140
|
+
- Keys without usage data take the **median of the other keys' non-exhausted
|
|
141
|
+
weights**, not plain round-robin. Round-robin is only the fallback when no
|
|
142
|
+
key has usage data at all (or when every key is exhausted and a request must
|
|
143
|
+
share one).
|
|
107
144
|
- A key whose account has no plan for its provider (403 `EntitlementError` /
|
|
108
145
|
`upgrade_required` from the usage endpoint) is dropped from rotation without
|
|
109
146
|
tripping the circuit breaker, and returns as soon as a later refresh sees it
|
|
@@ -118,14 +155,16 @@ object for non-streaming bodies — is booked against the key that served the
|
|
|
118
155
|
request. Totals are persisted to `~/.config/saros/token-usage.json` (debounced
|
|
119
156
|
atomic writes) and shown by `saros-proxy usage`.
|
|
120
157
|
|
|
121
|
-
This is the
|
|
122
|
-
|
|
123
|
-
`saros-proxy usage`
|
|
158
|
+
This is the fallback signal: when a provider has no quota endpoint, or its usage
|
|
159
|
+
call fails, absolute token totals and request counts per key are what
|
|
160
|
+
`saros-proxy usage` can still show.
|
|
124
161
|
|
|
125
162
|
#### Circuit Breaker
|
|
126
163
|
|
|
127
164
|
Keys that return auth/quota errors (`401`, quota `4xx/5xx`) enter a cooldown
|
|
128
|
-
and are lazily re-enabled after it expires
|
|
165
|
+
and are lazily re-enabled after it expires — or immediately by a successful
|
|
166
|
+
response through the same key, which happens when tier 2 shares one key between
|
|
167
|
+
concurrent requests and only one of them fails. Timeouts and connection errors do
|
|
129
168
|
**not** disable keys — they are shared-upstream failures, so Saros keeps
|
|
130
169
|
cycling instead of locking up with "all keys unavailable".
|
|
131
170
|
|
|
@@ -283,6 +322,11 @@ Test that it's alive:
|
|
|
283
322
|
```bash
|
|
284
323
|
curl http://127.0.0.1:3000/health
|
|
285
324
|
|
|
325
|
+
# Why is a key out of rotation? The `keys` block says it (never the key itself):
|
|
326
|
+
# "keys": [{"label":"work","provider":"commandcode","enabled":false,
|
|
327
|
+
# "reason":"auth","lastError":{"status":401},
|
|
328
|
+
# "retryAfterMs":43876}]
|
|
329
|
+
|
|
286
330
|
# Query models
|
|
287
331
|
curl http://127.0.0.1:3000/zen/go/v1/models
|
|
288
332
|
|
|
@@ -335,10 +379,15 @@ keys:
|
|
|
335
379
|
- label: cc-console-key
|
|
336
380
|
key: sk-your-console-key-here... # ambiguous prefix → set provider explicitly
|
|
337
381
|
provider: commandcode
|
|
382
|
+
- label: old-account
|
|
383
|
+
key: sk-you...0000
|
|
384
|
+
enabled: false # retired: kept in config, never used
|
|
338
385
|
```
|
|
339
386
|
|
|
340
387
|
The `provider` field is optional — it wins over prefix inference and is required only when a CommandCode `sk-` console key must be distinguished from an OpenCode-Go key. When keys are encrypted at rest (prefix hidden), the provider from setup time is persisted alongside.
|
|
341
388
|
|
|
389
|
+
The `enabled` field is optional (default `true`). Setting `enabled: false` retires a key without deleting it: it never enters rotation, is never counted as a failure, and `/health` reports it as `disabledByConfig: true` (the 503 lists it as `disabled_by_config`). A revoked key is usually better retired than deleted — the credential stays for the record and comes back with a one-word edit.
|
|
390
|
+
|
|
342
391
|
**Per-provider upstream override (optional):**
|
|
343
392
|
|
|
344
393
|
```yaml
|
|
@@ -357,10 +406,11 @@ OPENCODE_GO_KEYS="account1:sk-xxx,cc:user_yyy"
|
|
|
357
406
|
- Clients always speak the canonical OpenCode-Go shapes (`/zen/go/v1/...` or the `/v1/...` alias).
|
|
358
407
|
- When a request is served by a CommandCode key, Saros remaps the route to CommandCode's OpenAI-compatible surface (`/provider/v1/...`) and injects the provider's identity headers — transparent to the client.
|
|
359
408
|
- Models uniquely named by a provider (e.g. `claude-*` or vendor-prefixed ids on CommandCode) are routed to a key of that provider first; other providers remain as fallback.
|
|
360
|
-
- Synced harness configs may list provider-specific models as `model@commandcode`; the suffix selects the provider and is stripped before forwarding.
|
|
361
|
-
- The `/health` endpoint reports per-provider key counts under `providers
|
|
409
|
+
- Synced harness configs may list provider-specific models as `model@commandcode`; the suffix selects the provider and is stripped before forwarding. The pin is authoritative: key selection is restricted to that provider, so a pin whose provider cannot serve the model still reaches that upstream and surfaces its error instead of a local 503.
|
|
410
|
+
- The `/health` endpoint reports per-provider key counts under `providers`, and a `keys` array with each key's state: `enabled`, `entitled`, `consecutiveFailures`, `disabledAt`, `retryAfterMs`, and `lastError` (`status` + `reason`: `auth`, `quota`, `rate_limit`, `server`). The credential is never part of the response.
|
|
411
|
+
- When no key can serve a request, the `503` body says which keys are out and why — `keys[]` with `reason`/`lastStatus`/`retryAfterMs`, plus a `causes[]` list (revoked key, exhausted quota, rate limit, missing plan, or a `@provider` pin with no usable key). The legacy `error` field (`All API keys are temporarily unavailable`) is still present.
|
|
362
412
|
- OpenCode-Go requires a stable per-conversation session id (`x-opencode-session`, used for routing and prompt caching). Saros forwards a client-supplied value untouched and derives one from the conversation (model + first user turn) when the client sends none — harnesses that only attach the header on some request paths would otherwise get `400 MissingSessionID`.
|
|
363
|
-
- Usage-based weighted rotation
|
|
413
|
+
- Usage-based weighted rotation uses each provider's queryable usage endpoint (OpenCode-Go `GET /zen/go/v1/usage`, CommandCode `GET /alpha/billing/credits`). Both contribute a monthly percentage of their own budget; token consumption stays tracked per key and shown by `saros-proxy usage` (see [Usage-Weighted Rotation](#usage-weighted-rotation) and [Token Accounting](#token-accounting)).
|
|
364
414
|
|
|
365
415
|
#### Smoke test (key identification)
|
|
366
416
|
|
|
@@ -372,33 +422,100 @@ CC_KEY=user_your-token npx tsx scripts/live-identify-check.ts
|
|
|
372
422
|
|
|
373
423
|
### API Key Encryption
|
|
374
424
|
|
|
375
|
-
Keys can be
|
|
425
|
+
Keys can be stored as `enc:v1:` envelopes instead of plaintext. An envelope is
|
|
426
|
+
`enc:v1:<salt>.<nonce>.<tag>.<ciphertext>`: scrypt (N=2^15, r=8, p=1, 32 B)
|
|
427
|
+
derives an AES-256-GCM key, and the AAD binds the envelope version and the key
|
|
428
|
+
label, so a ciphertext copied onto another key entry fails authentication.
|
|
429
|
+
|
|
430
|
+
**Encrypt existing plaintext keys** (the old file is copied to
|
|
431
|
+
`config.yaml.backup` once; a second run is a no-op; providers are inferred and
|
|
432
|
+
persisted so encrypted keys still route correctly):
|
|
376
433
|
|
|
377
|
-
**Setup with encryption:**
|
|
378
434
|
```bash
|
|
379
|
-
|
|
380
|
-
#
|
|
381
|
-
|
|
435
|
+
saros-proxy keys encrypt
|
|
436
|
+
# or: saros-proxy keys encrypt --config /etc/saros/config.yaml
|
|
437
|
+
# or: saros-proxy keys encrypt --no-backup
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
The backup is written **in plaintext** (mode 0600) on purpose: it is the
|
|
441
|
+
recovery path if the passphrase is lost. Delete it after the first successful
|
|
442
|
+
boot, or pass `--no-backup` to skip it. A symlinked config (dotfile setups) is
|
|
443
|
+
rewritten through its real target — the link survives and the target is
|
|
444
|
+
encrypted.
|
|
445
|
+
|
|
446
|
+
**Cost of the KDF:** the envelope uses scrypt N=2^15, r=8, p=1 (about 32 MiB
|
|
447
|
+
and ~70 ms per key on a current laptop). Boot derives the key once per
|
|
448
|
+
encrypted entry, so 10 keys add roughly 0.7 s. Raising N to 2^17 (OWASP's
|
|
449
|
+
recommendation) multiplies that boot cost by ~4 per key; do it only together
|
|
450
|
+
with a migration, and remember that passphrase entropy — not the KDF — is what
|
|
451
|
+
an offline attacker attacks first.
|
|
452
|
+
|
|
453
|
+
**Passphrase sources, first match wins:**
|
|
454
|
+
|
|
455
|
+
1. `SAROS_PASSPHRASE` — for systemd, Docker and CI.
|
|
456
|
+
2. The OS secret store, accessed only through the platform CLI:
|
|
457
|
+
- Linux: `secret-tool lookup service saros-proxy account "$USER"`
|
|
458
|
+
- macOS: `security find-generic-password -s saros-proxy -a "$USER" -w`
|
|
459
|
+
- Windows: PowerShell DPAPI (`CurrentUser`) over
|
|
460
|
+
`%APPDATA%\saros\secret.dpapi`
|
|
461
|
+
3. A hidden TTY prompt — foreground `saros-proxy serve` only, never the daemon
|
|
462
|
+
child and never when stdin is not a TTY.
|
|
382
463
|
|
|
383
|
-
**Running with encrypted keys:**
|
|
384
464
|
```bash
|
|
385
|
-
|
|
386
|
-
|
|
465
|
+
saros-proxy secret set # store the passphrase through the OS CLI
|
|
466
|
+
saros-proxy secret status # which source would win + whether the store is populated
|
|
467
|
+
saros-proxy serve # foreground; may prompt if no source is available
|
|
387
468
|
```
|
|
388
469
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
470
|
+
`secret set` never writes the passphrase to a file and never prints it; it
|
|
471
|
+
warns when the value is shorter than 12 characters (scrypt makes guessing
|
|
472
|
+
expensive, but passphrase entropy is what actually protects the keys). On
|
|
473
|
+
Linux it is stored with `secret-tool store --label="saros-proxy passphrase"
|
|
474
|
+
service saros-proxy account "$USER"`; on macOS with `security
|
|
475
|
+
add-generic-password -U -s saros-proxy -a "$USER" -w`; on Windows PowerShell
|
|
476
|
+
`ProtectedData.Protect(..., 'CurrentUser')` writes the blob.
|
|
477
|
+
|
|
478
|
+
`secret status` distinguishes an absent entry (`not found`) from an
|
|
479
|
+
unavailable keyring (`keyring locked or error`), and the same reason is carried
|
|
480
|
+
into the "no passphrase source" boot error — a locked keyring is not reported
|
|
481
|
+
as an empty store.
|
|
482
|
+
|
|
483
|
+
**systemd-creds recipe** (keeps the passphrase out of the unit file and the
|
|
484
|
+
environment of the shell):
|
|
395
485
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
486
|
+
```bash
|
|
487
|
+
# One-time: encrypt stdin into a host+user-bound credential.
|
|
488
|
+
systemd-creds encrypt --name=saros-passphrase --with-key=host+user \
|
|
489
|
+
/dev/stdin /etc/saros/passphrase.cred
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
```ini
|
|
493
|
+
# /etc/systemd/system/saros-proxy.service
|
|
494
|
+
[Service]
|
|
495
|
+
LoadCredentialEncrypted=saros-passphrase:/etc/saros/passphrase.cred
|
|
496
|
+
ExecStart=/bin/sh -c 'SAROS_PASSPHRASE="$(cat "$CREDENTIALS_DIRECTORY/saros-passphrase")" exec /usr/bin/saros-proxy serve'
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
**Behaviour and compatibility:**
|
|
500
|
+
|
|
501
|
+
- Plaintext keys keep working; startup logs a loud warning and
|
|
502
|
+
`keys encrypt` fixes them.
|
|
503
|
+
- A config with `enc:v1:` values and no available source exits `1` with a
|
|
504
|
+
message naming the three options. A wrong passphrase also exits `1` and
|
|
505
|
+
names the key label — never the key or the passphrase.
|
|
506
|
+
- `GET /health` reports `"encrypted": true` per key; it never returns the
|
|
507
|
+
ciphertext or the plaintext. The flag is derived from the value being an
|
|
508
|
+
envelope — an `encrypted: true` field on a plaintext entry is stripped, so
|
|
509
|
+
the signal cannot be spoofed from YAML.
|
|
510
|
+
- Legacy `enc:scrypt:` keys still decrypt with `OPENCODE_GO_ENCRYPTION_KEY`
|
|
511
|
+
(with a warning); `keys encrypt` leaves them untouched.
|
|
512
|
+
- `OPENCODE_GO_KEYS` is always plaintext (CI use) and is not covered by the
|
|
513
|
+
warning.
|
|
514
|
+
- Docker can pass `SAROS_PASSPHRASE` or mount a config whose passphrase comes
|
|
515
|
+
from a Docker secret; the container has no OS keyring of its own.
|
|
516
|
+
|
|
517
|
+
The honest threat model — what each option protects and what it does not — is
|
|
518
|
+
in [Security](#security).
|
|
402
519
|
|
|
403
520
|
### CLI Flags
|
|
404
521
|
|
|
@@ -526,9 +643,11 @@ Override with `--config`:
|
|
|
526
643
|
saros-proxy start --port 3000 --config /path/to/config.yaml
|
|
527
644
|
```
|
|
528
645
|
|
|
529
|
-
The PID file
|
|
646
|
+
The PID file, `daemon.log` and captures live in the same directory as `config.yaml` — `~/.config/saros/` on Linux/macOS, `%LOCALAPPDATA%\saros\` on Windows. An installation started before 0.11.0 keeps its pid under `~/.config/saros`; set `SAROS_ALLOW_LEGACY_PID=1` once to stop it, or the new layout takes over on the next start. Without the flag the pre-0.11 PID file is inert — it is never read, migrated or deleted (only `stat`ed, so `stop`/`status` can name it and point at `SAROS_ALLOW_LEGACY_PID=1`) — because that path lives in the operator's real home and an isolated run (a test with a temp config dir, a throwaway container shell) must not signal a live daemon. With the flag set, a live legacy PID is read and migrated into the new directory on first use. `saros-proxy stop` removes the PID file only once the process is confirmed gone: when the kill fails it keeps the handle and exits non-zero, so the next `stop` still finds the daemon (instead of printing "no running proxy daemon" and exiting 0) and `restart` never announces a stop it could not confirm nor start over a busy port. A failed stop also names the exact file it acted on, and removing it never touches the other layout's handle. When the state dir holds no PID but a pre-0.11 one exists, `stop` says where it is and exits non-zero if something still answers `/health` on the configured port — an upgraded install cannot be reported as stopped while it still serves. The child process runs with `NODE_ENV=production` (JSON/NDJSON logging), and **both** its stdout (pino) and stderr are appended to `daemon.log` — the same descriptor for both, with no pipe whose read end the exiting launcher could take away. The file rotates at start: past 10 MB it becomes `daemon.log.1` and up to five are kept. Every boot writes an `evt:"boot"` line — version, node, config path, port, key labels/providers, bundled model count — so an empty log now means the daemon never started, not that logging is silent. When a fresh child dies during startup, `saros-proxy start` reads the lines this boot appended to that same log and reports the reason (`EADDRINUSE`, missing API keys, or the last six lines) before exiting non-zero.
|
|
647
|
+
|
|
648
|
+
`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) and, from the daemon's `/health` payload, one line per key with its state (`Keys: 1/2 enabled`, then `work [commandcode] — disabled: auth (401), retry in 42s`). `saros-proxy start` refuses to start a second instance when the configured port is already serving. `saros-proxy stop` exits non-zero whenever something still answers `/health` on the configured port, even when it has no PID file to remove — the process was started outside the daemon or lost its handle.
|
|
530
649
|
|
|
531
|
-
|
|
650
|
+
Every request writes exactly one `evt:"request.complete"` line (requestId, path, model, provider, keyLabel, status, durationMs, stream, attempt), with key selection at `debug` and upstream failures at `warn`. The full event and redaction contract is in [docs/logging.md](docs/logging.md).
|
|
532
651
|
|
|
533
652
|
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.
|
|
534
653
|
|
|
@@ -644,9 +763,9 @@ The model list written to each harness comes from the live provider catalogs (op
|
|
|
644
763
|
Two kinds of upstream quota are visible in the provider documentation but are **not** modelled by Saros, on purpose — the proxy rotates keys and reasons about keys, not about what a single model or plan allows:
|
|
645
764
|
|
|
646
765
|
- **Model-level free tiers (CommandCode).** `glyph-cluster:free` is capped at 100 requests/day, `beam:free` has no daily cap, and the `laguna-s-2.1-free` tiers cost no credits. Saros counts tokens per key (`token-usage.json`) and per model, but it does not refuse a request because a *model* reached its daily free quota — the upstream returns the error and the key's circuit breaker decides what happens next.
|
|
647
|
-
- **Plan windows (OpenCode Go).** The Go plan meters each model against `5-hour — 20% of the monthly limit`, `weekly — 50%` and `monthly — 100%`.
|
|
766
|
+
- **Plan windows (OpenCode Go).** The Go plan meters each model against `5-hour — 20% of the monthly limit`, `weekly — 50%` and `monthly — 100%`. What was consumed is what feeds weighted rotation (see [Usage-Weighted Rotation](#usage-weighted-rotation)), but the proxy does not refuse a request because a model crossed a window: the upstream returns the error and that is what the client sees.
|
|
648
767
|
|
|
649
|
-
Both are documented here so the behaviour is not mistaken for a bug. Requests through the alternate OpenAI/Anthropic surfaces (`/v1/responses`, `/v1/messages`) and through the decision endpoint (`/v1/systemone`) are forwarded and counted: streamed usage nested under `response.usage` and the `input_tokens`/`output_tokens` shape are booked like any other call.
|
|
768
|
+
Both are documented here so the behaviour is not mistaken for a bug. Requests through the alternate OpenAI/Anthropic surfaces (`/v1/responses`, `/v1/messages`) and through the decision endpoint (`/v1/systemone`) are forwarded and counted: streamed usage nested under `response.usage` (Responses), the counts Anthropic splits between `message_start.message.usage` and `message_delta.usage` (Messages), and the `input_tokens`/`output_tokens` shape are booked like any other call — with one exception: Anthropic **cache** counters (`cache_read_input_tokens`, `cache_creation_input_tokens`) are not counted, so prompt-cached Messages traffic is under-booked on the prompt side.
|
|
650
769
|
|
|
651
770
|
---
|
|
652
771
|
|
|
@@ -670,21 +789,41 @@ NODE_ENV=development npx tsx src/index.ts
|
|
|
670
789
|
NODE_ENV=production npx tsx src/index.ts
|
|
671
790
|
```
|
|
672
791
|
|
|
673
|
-
### See which key served a request
|
|
792
|
+
### See which key and provider served a request
|
|
674
793
|
|
|
675
|
-
|
|
794
|
+
Responses served by an upstream key carry three debug headers:
|
|
676
795
|
|
|
677
796
|
| Header | Example | What it is |
|
|
678
797
|
|---|---|---|
|
|
679
798
|
| `X-Proxy-Key-Label` | `primary` | Which key was used |
|
|
799
|
+
| `X-Proxy-Provider` | `commandcode` | Provider of that key (`opencode-go` or `commandcode`) |
|
|
680
800
|
| `X-Proxy-Request-Id` | `550e8400-e29b-41d4-a716-446655440000` | Unique ID for log correlation |
|
|
681
801
|
|
|
682
802
|
```bash
|
|
683
|
-
curl -s -D - http://127.0.0.1:3000/
|
|
803
|
+
curl -s -D - http://127.0.0.1:3000/v1/chat/completions \
|
|
804
|
+
-H "Content-Type: application/json" \
|
|
805
|
+
-d '{"model":"gpt-4","messages":[{"role":"user","content":"Hello"}]}' | grep -i x-proxy
|
|
684
806
|
# X-Proxy-Key-Label: primary
|
|
807
|
+
# X-Proxy-Provider: opencode-go
|
|
685
808
|
# X-Proxy-Request-Id: 550e8400-e29b-41d4-a716-446655440000
|
|
686
809
|
```
|
|
687
810
|
|
|
811
|
+
`X-Proxy-Provider` reports the configured provider of the key that actually served the request. The credential prefix is not re-inferred at routing time: an explicit `provider:` in `config.yaml` wins, so a key declared `provider: commandcode` is sent to CommandCode and reported as `commandcode` even with an `sk-…` credential. For a pinned `model@provider` request the value equals the suffix because the selected key belongs to that provider — not because the header parses the model id.
|
|
812
|
+
|
|
813
|
+
These headers are emitted only on 2xx responses served by an upstream key. The local routes (`GET /health`, `GET /v1/models`, `GET /zen/go/v1/models`, `GET /debug/recent`) and the 413/429 rejections carry none. The proxy's own error responses — 503 no key, 502 retries exhausted, the 502 synthesized for a bodyless stream, and a forwarded 4xx it re-serializes — omit them even when a key was involved: use the body's `requestId`, and `/debug/recent` still records the `keyLabel`. `/v1/models` is answered by the catalogue handler (`models-fetcher`, no key selected, no provider attribution), which is why it is outside the header contract.
|
|
814
|
+
|
|
815
|
+
Browser clients can read the three headers: the CORS middleware lists them in `Access-Control-Expose-Headers`.
|
|
816
|
+
|
|
817
|
+
### Inspect the last requests
|
|
818
|
+
|
|
819
|
+
```bash
|
|
820
|
+
curl -s http://127.0.0.1:3000/debug/recent | jq
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
`GET /debug/recent` returns `{capacity, count, entries}` with the last 200 proxied requests, oldest first. Each entry mirrors the `evt:"request.complete"` log line: `requestId`, `at` (ISO, when the response was produced — for a stream that is its headers, not the end of the stream), `method`, `path`, `model`, `provider`, `keyLabel`, `status`, `durationMs`, `stream`, `attempt`. It lives in memory only and never stores keys, headers or bodies — only the public key label — so it starts empty on restart.
|
|
824
|
+
|
|
825
|
+
Disable it with `SAROS_DEBUG_BUFFER=0`: the endpoint answers `404` and no entry is kept. Like `/health`, it is unauthenticated and metadata-only: `requestId`, paths, models, providers, key labels, status and timing — never keys, headers or bodies. It is designed for the loopback bind; a deployment reachable from the network (including a container/port binding beyond `127.0.0.1`) exposes those request paths, model names and key labels to anyone who can reach the port.
|
|
826
|
+
|
|
688
827
|
### Common issues
|
|
689
828
|
|
|
690
829
|
| Symptom | Likely cause | Fix |
|
|
@@ -693,6 +832,7 @@ curl -s -D - http://127.0.0.1:3000/zen/go/v1/models | grep -i x-proxy
|
|
|
693
832
|
| `ECONNREFUSED` on startup | Port already in use | Change port or kill the existing process |
|
|
694
833
|
| `504 Gateway Timeout` | Upstream too slow | Increase `requestTimeoutMs` |
|
|
695
834
|
| `All API keys are temporarily unavailable` | All keys disabled by circuit breaker | Wait for cooldown or check upstream status |
|
|
835
|
+
| Upstream `403` with `error code: 1010` | Cloudflare in front of the provider rejected the `User-Agent` the client sent | Saros sets the provider's own identity (`cli`); if you see this, the request bypassed the proxy or a provider lost its `extraUpstreamHeaders` |
|
|
696
836
|
| CORS error | Client origin not allowed | Add it to `allowedOrigins` in config |
|
|
697
837
|
|
|
698
838
|
---
|
|
@@ -771,6 +911,49 @@ test/
|
|
|
771
911
|
|
|
772
912
|
For vulnerability reporting, see [SECURITY.md](SECURITY.md).
|
|
773
913
|
|
|
914
|
+
### Threat model for API keys at rest
|
|
915
|
+
|
|
916
|
+
The envelope protects the key **file**. It does not make a credential
|
|
917
|
+
unreachable by code running as the same user — every option below ultimately
|
|
918
|
+
hands the plaintext to the proxy process, which that code can inspect.
|
|
919
|
+
|
|
920
|
+
| Scenario | Plaintext config | `enc:v1` + `SAROS_PASSPHRASE` | `enc:v1` + OS store | `enc:v1` + TTY prompt |
|
|
921
|
+
|---|---|---|---|---|
|
|
922
|
+
| Config file copied to another machine/user | No protection | Protected (passphrase not in the file) | Protected (DPAPI/Keychain is user-bound) | Protected |
|
|
923
|
+
| Dotfile sync / cloud backup / git accident | Leaks | Protected | Protected | Protected |
|
|
924
|
+
| Backup of the config directory only | Leaks | Protected only after deleting `config.yaml.backup` (the migration backup is plaintext) | Same caveat | Same caveat |
|
|
925
|
+
| Shoulder surfing the config file | Leaks | Protected | Protected | Protected |
|
|
926
|
+
| Another local process **running as the same user** | Reads the file | Can read the env (`/proc/<pid>/environ`) | Can call the same OS CLI / read the same DPAPI blob | Can read the terminal or memory |
|
|
927
|
+
| Malware running as the same user | Reads the file | No protection | No protection | No protection |
|
|
928
|
+
| Root / Administrator | Reads the file | No protection | No protection (may impersonate the user) | No protection |
|
|
929
|
+
| Offline brute force of a stolen config | N/A | scrypt N=2^15 slows guessing; strength = passphrase entropy | Same | Same |
|
|
930
|
+
| Passphrase visible in `ps`/shell history | N/A | Avoid `SAROS_PASSPHRASE=… command`; export it or use systemd-creds | `secret set` on macOS passes `-w` on the command line briefly | Typed hidden, not echoed |
|
|
931
|
+
|
|
932
|
+
Practical guidance:
|
|
933
|
+
|
|
934
|
+
- Use the OS store for an interactive workstation; it avoids the passphrase
|
|
935
|
+
being present in every child process environment.
|
|
936
|
+
- Use `SAROS_PASSPHRASE` for containers/systemd; prefer `systemd-creds` (recipe
|
|
937
|
+
above) over a unit-file `Environment=` line.
|
|
938
|
+
- The passphrase must be high entropy and independent from the API keys; the
|
|
939
|
+
envelope only makes an offline attack expensive, not impossible. `secret set`
|
|
940
|
+
warns below 12 characters; no length is enforced.
|
|
941
|
+
- `keys encrypt` writes `<config>.backup` in plaintext (0600) once. Delete it
|
|
942
|
+
after the first successful boot, or use `--no-backup`. Any directory/dotfile
|
|
943
|
+
backup taken before that deletion contains the original keys.
|
|
944
|
+
- `saros-proxy secret status` shows which source would win and never prints a
|
|
945
|
+
value.
|
|
946
|
+
|
|
947
|
+
### Other controls
|
|
948
|
+
|
|
949
|
+
- HTTPS-only upstream (SSRF prevention)
|
|
950
|
+
- API key masking in logs, plus envelope/passphrase redaction patterns and
|
|
951
|
+
pino redact paths for `passphrase`, `secret`, `secretValue`, `masterKey`,
|
|
952
|
+
`encryptionKey` and `SAROS_PASSPHRASE`
|
|
953
|
+
- CORS origin validation
|
|
954
|
+
- Rate limiting: 100 req/min per IP (hardcoded)
|
|
955
|
+
- Non-root Docker container
|
|
956
|
+
|
|
774
957
|
---
|
|
775
958
|
|
|
776
959
|
## License
|
package/dist/cli/daemon.d.ts
CHANGED
|
@@ -9,13 +9,42 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Daemon lifecycle:
|
|
11
11
|
* start → refuses if the port is already serving, then spawns detached
|
|
12
|
-
* `node dist/index.js` process, saves PID,
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* `node dist/index.js` process, saves PID, appends child stdout
|
|
13
|
+
* and stderr to daemon.log
|
|
14
|
+
* stop → kills the process (taskkill /T /F on Windows) and removes the
|
|
15
|
+
* PID file only once the process is confirmed gone; exits 1 when
|
|
16
|
+
* something still serves the port
|
|
17
|
+
* restart → kills running instance (if any), waits for it to disappear,
|
|
18
|
+
* then starts fresh
|
|
15
19
|
* status → checks PID file, falls back to an os-agnostic /health port
|
|
16
20
|
* probe (instances started outside the daemon have no PID file),
|
|
17
21
|
* and prints the enabled harnesses
|
|
18
22
|
*/
|
|
23
|
+
/**
|
|
24
|
+
* Open daemon.log for append (0600 — it carries upstream error bodies).
|
|
25
|
+
* Returns null when the file cannot be opened, so callers fall back to
|
|
26
|
+
* discarding output. Used for the child's stdout and stderr, which share this
|
|
27
|
+
* single file. Exported so the wiring can be asserted in tests.
|
|
28
|
+
*/
|
|
29
|
+
export declare function openDaemonLog(): number | null;
|
|
30
|
+
/**
|
|
31
|
+
* Check if a process is alive by PID.
|
|
32
|
+
*
|
|
33
|
+
* `process.kill(pid, 0)` is the portable liveness probe: Node maps it to
|
|
34
|
+
* OpenProcess on Windows, so a dead PID fails with ESRCH and an EPERM/EACCES
|
|
35
|
+
* answer means "the PID exists, it is just not ours to signal" → alive.
|
|
36
|
+
*
|
|
37
|
+
* The previous win32 path ran `tasklist /FI "PID eq <pid>"`, which exits 0
|
|
38
|
+
* even when no task matches — every PID looked alive (#83).
|
|
39
|
+
*/
|
|
40
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Kill a process and all its children. Returns whether the target is gone.
|
|
43
|
+
*
|
|
44
|
+
* Output is piped (never inherited): taskkill's "not found" text used to land
|
|
45
|
+
* in the caller's console and PowerShell surfaced it as a NativeCommandError.
|
|
46
|
+
*/
|
|
47
|
+
export declare function killProcess(pid: number): boolean;
|
|
19
48
|
/**
|
|
20
49
|
* Probe the proxy's /health endpoint on a port. Returns the parsed body when
|
|
21
50
|
* the endpoint answers with status ok — the strongest cross-platform signal
|
|
@@ -27,11 +56,14 @@ export declare function probeProxyHealth(port: number): Promise<{
|
|
|
27
56
|
body?: Record<string, unknown>;
|
|
28
57
|
}>;
|
|
29
58
|
export declare function daemonStart(port?: number, configPath?: string): Promise<void>;
|
|
30
|
-
export declare function daemonStop(): void
|
|
59
|
+
export declare function daemonStop(): Promise<void>;
|
|
31
60
|
/**
|
|
32
|
-
* Restart the proxy daemon: stop the running instance (if any), wait
|
|
33
|
-
*
|
|
61
|
+
* Restart the proxy daemon: stop the running instance (if any), wait for it
|
|
62
|
+
* to actually disappear and the port to release, then start a fresh instance.
|
|
34
63
|
* Reuses daemonStart which owns its own exit path via setTimeout.
|
|
64
|
+
*
|
|
65
|
+
* "Restarting: stopped daemon" used to print before the kill landed, so a
|
|
66
|
+
* failed stop was followed by a start that died on the busy port.
|
|
35
67
|
*/
|
|
36
68
|
export declare function daemonRestart(port?: number, configPath?: string): void;
|
|
37
69
|
export interface DaemonState {
|