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.
Files changed (77) hide show
  1. package/README.md +261 -78
  2. package/dist/cli/daemon.d.ts +38 -6
  3. package/dist/cli/daemon.js +524 -109
  4. package/dist/cli/daemon.js.map +1 -1
  5. package/dist/cli/dsh-config.js +34 -9
  6. package/dist/cli/dsh-config.js.map +1 -1
  7. package/dist/cli/help.js +3 -0
  8. package/dist/cli/help.js.map +1 -1
  9. package/dist/cli/key-state.d.ts +21 -0
  10. package/dist/cli/key-state.js +57 -0
  11. package/dist/cli/key-state.js.map +1 -0
  12. package/dist/cli/keys.d.ts +28 -0
  13. package/dist/cli/keys.js +185 -0
  14. package/dist/cli/keys.js.map +1 -0
  15. package/dist/cli/overview.js +6 -1
  16. package/dist/cli/overview.js.map +1 -1
  17. package/dist/cli/secret.d.ts +31 -0
  18. package/dist/cli/secret.js +89 -0
  19. package/dist/cli/secret.js.map +1 -0
  20. package/dist/cli/usage.js +20 -10
  21. package/dist/cli/usage.js.map +1 -1
  22. package/dist/config.d.ts +15 -1
  23. package/dist/config.js +98 -25
  24. package/dist/config.js.map +1 -1
  25. package/dist/constants.d.ts +2 -0
  26. package/dist/constants.js +2 -0
  27. package/dist/constants.js.map +1 -1
  28. package/dist/debug-buffer.d.ts +58 -0
  29. package/dist/debug-buffer.js +42 -0
  30. package/dist/debug-buffer.js.map +1 -0
  31. package/dist/index.js +30 -4
  32. package/dist/index.js.map +1 -1
  33. package/dist/log-redact.d.ts +18 -0
  34. package/dist/log-redact.js +41 -0
  35. package/dist/log-redact.js.map +1 -0
  36. package/dist/logger.d.ts +6 -0
  37. package/dist/logger.js +30 -0
  38. package/dist/logger.js.map +1 -1
  39. package/dist/models-fetcher.js +2 -0
  40. package/dist/models-fetcher.js.map +1 -1
  41. package/dist/providers/commandcode.d.ts +20 -5
  42. package/dist/providers/commandcode.js +115 -7
  43. package/dist/providers/commandcode.js.map +1 -1
  44. package/dist/providers/index.d.ts +2 -2
  45. package/dist/providers/index.js +2 -2
  46. package/dist/providers/index.js.map +1 -1
  47. package/dist/providers/infer.d.ts +11 -0
  48. package/dist/providers/infer.js.map +1 -1
  49. package/dist/providers/opencode-go.d.ts +8 -0
  50. package/dist/providers/opencode-go.js +35 -7
  51. package/dist/providers/opencode-go.js.map +1 -1
  52. package/dist/providers/types.d.ts +10 -2
  53. package/dist/proxy-logic.d.ts +101 -7
  54. package/dist/proxy-logic.js +197 -41
  55. package/dist/proxy-logic.js.map +1 -1
  56. package/dist/proxy.d.ts +33 -1
  57. package/dist/proxy.js +369 -67
  58. package/dist/proxy.js.map +1 -1
  59. package/dist/secrets/boot.d.ts +33 -0
  60. package/dist/secrets/boot.js +65 -0
  61. package/dist/secrets/boot.js.map +1 -0
  62. package/dist/secrets/envelope.d.ts +44 -0
  63. package/dist/secrets/envelope.js +132 -0
  64. package/dist/secrets/envelope.js.map +1 -0
  65. package/dist/secrets/source.d.ts +130 -0
  66. package/dist/secrets/source.js +332 -0
  67. package/dist/secrets/source.js.map +1 -0
  68. package/dist/token-usage.d.ts +9 -2
  69. package/dist/token-usage.js +20 -4
  70. package/dist/token-usage.js.map +1 -1
  71. package/dist/usage-client.d.ts +43 -13
  72. package/dist/usage-client.js +37 -54
  73. package/dist/usage-client.js.map +1 -1
  74. package/dist/validation.d.ts +3 -3
  75. package/dist/validation.js +3 -3
  76. package/dist/validation.js.map +1 -1
  77. 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
- │ Your App / OpenCode │
57
- │ │
58
- │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
59
- │ │ Request 1│ │ Request 2│ │ Request 3│ ... │
60
- │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
61
- └────────┼──────────────┼──────────────┼──────────────────────┘
62
- │ │ │
63
- └──────────────┼──────────────┘
64
- │
65
- ▼
66
- ┌─────────────────────────────────────────────────────────────┐
67
- │ Saros (:3000) │
68
- │ │
69
- │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
70
- │ │ Key Selector │─▶│ Circuit │─▶│ Request │ │
71
- │ │ Round-Robin │ │ Breaker │ │ Forwarder │ │
72
- │ │ + Failover │ │ Threshold + │ │ + Timeout + │ │
73
- │ │ │ │ Cooldown │ │ SSE │ │
74
- │ └──────────────┘ └──────────────┘ └──────┬───────┘ │
75
- └─────────────────────────────────────────────┼───────────────┘
76
- │
77
- ┌───────────┼───────────┐
78
- ▼ ▼ ▼
79
- ┌──────────┐ ┌──────────┐ ┌──────────┐
80
- │ Key A │ │ Key B │ │ Key C │
81
- │ sk-abc… │ │ sk-def… │ │ user_… │
82
- └────┬─────┘ └────┬─────┘ └────┬─────┘
83
- │ │ │
84
- └────────────┼────────────┘
85
- │
86
- ┌─────────────────────┴─────────────────────┐
87
- ▼ ▼
88
- ┌───────────────────────┐ ┌──────────────────────────┐
89
- │ OpenCode-Go API │ │ CommandCode API │
90
- │ https://opencode.ai│ │ https://api.commandcode.ai│
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 the upstream usage API for each key (5h / weekly / monthly windows)
101
- and weights rotation by **remaining** quota. If key X is 30% used and key Y is
102
- 70% used, 10 requests are distributed 7 → X and 3 → Y — proportional balancing,
103
- not hard cutoffs, so neither key absorbs the full load when the other runs low.
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
- - Without usage data, falls back to even round-robin.
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 only consumption signal available for providers without a
122
- queryable quota API (CommandCode): their keys report no percentages, but
123
- `saros-proxy usage` shows absolute token totals and request counts per key.
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. Timeouts and connection errors do
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 applies to providers with a queryable usage API (OpenCode-Go). CommandCode keys participate in rotation without usage data, but their token consumption is tracked per key and shown by `saros-proxy usage` (see [Token Accounting](#token-accounting)).
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 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.
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
- npm run setup
380
- # Choose to encrypt keys and enter a master key (min 16 chars)
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
- export OPENCODE_GO_ENCRYPTION_KEY="your-master-key"
386
- npx tsx src/index.ts
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
- **Docker with encrypted keys:**
390
- ```yaml
391
- # docker-compose.yml
392
- environment:
393
- - OPENCODE_GO_ENCRYPTION_KEY=your-master-key
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
- **Notes:**
397
- - Encrypted keys use AES-256-GCM with scrypt KDF (N=16384, r=8, p=1)
398
- - Each key has its own random salt and IV
399
- - Plaintext keys (`sk-...`) still work — backward compatible
400
- - `OPENCODE_GO_KEYS` env var is always plaintext (for CI/CD)
401
- - If keys are encrypted but no `OPENCODE_GO_ENCRYPTION_KEY` is set, startup fails with a clear error
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 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`.
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
- `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.
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%`. Those windows belong to the subscription: `/zen/go/v1/usage` reports what was consumed, and Saros surfaces it, but the proxy does not predict window exhaustion or re-route on it.
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
- Saros adds two debug headers to every response:
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/zen/go/v1/models | grep -i x-proxy
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
@@ -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, logs child stderr
13
- * stop → kills the process (taskkill /T /F on Windows), removes PID file
14
- * restart → kills running instance (if any), waits, then starts fresh
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
- * briefly for the port to release, then start a fresh instance.
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 {