saros-proxy 0.11.0 → 1.1.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 (62) hide show
  1. package/README.md +149 -60
  2. package/assets/models.json +3349 -0
  3. package/dist/cli/autostart.d.ts +13 -6
  4. package/dist/cli/autostart.js +317 -60
  5. package/dist/cli/autostart.js.map +1 -1
  6. package/dist/cli/daemon.js +66 -8
  7. package/dist/cli/daemon.js.map +1 -1
  8. package/dist/cli/harness-models.d.ts +5 -4
  9. package/dist/cli/harness-models.js +77 -30
  10. package/dist/cli/harness-models.js.map +1 -1
  11. package/dist/cli/help.js +5 -0
  12. package/dist/cli/help.js.map +1 -1
  13. package/dist/cli/opencode-config.d.ts +1 -0
  14. package/dist/cli/opencode-config.js +7 -6
  15. package/dist/cli/opencode-config.js.map +1 -1
  16. package/dist/cli/usage.js +13 -10
  17. package/dist/cli/usage.js.map +1 -1
  18. package/dist/constants.d.ts +2 -0
  19. package/dist/constants.js +2 -0
  20. package/dist/constants.js.map +1 -1
  21. package/dist/index.js +29 -36
  22. package/dist/index.js.map +1 -1
  23. package/dist/model-ladder.d.ts +30 -0
  24. package/dist/model-ladder.js +45 -0
  25. package/dist/model-ladder.js.map +1 -0
  26. package/dist/models-catalog.d.ts +25 -4
  27. package/dist/models-catalog.js +41 -12
  28. package/dist/models-catalog.js.map +1 -1
  29. package/dist/models-dev.d.ts +72 -0
  30. package/dist/models-dev.js +196 -0
  31. package/dist/models-dev.js.map +1 -0
  32. package/dist/models-fetcher.js +2 -0
  33. package/dist/models-fetcher.js.map +1 -1
  34. package/dist/models-refresh.d.ts +144 -0
  35. package/dist/models-refresh.js +522 -0
  36. package/dist/models-refresh.js.map +1 -0
  37. package/dist/models-sync.d.ts +6 -10
  38. package/dist/models-sync.js +26 -60
  39. package/dist/models-sync.js.map +1 -1
  40. package/dist/providers/commandcode.d.ts +20 -5
  41. package/dist/providers/commandcode.js +115 -7
  42. package/dist/providers/commandcode.js.map +1 -1
  43. package/dist/providers/index.d.ts +2 -2
  44. package/dist/providers/index.js +2 -2
  45. package/dist/providers/index.js.map +1 -1
  46. package/dist/providers/opencode-go.d.ts +8 -0
  47. package/dist/providers/opencode-go.js +35 -7
  48. package/dist/providers/opencode-go.js.map +1 -1
  49. package/dist/providers/types.d.ts +10 -2
  50. package/dist/proxy-logic.d.ts +27 -4
  51. package/dist/proxy-logic.js +86 -32
  52. package/dist/proxy-logic.js.map +1 -1
  53. package/dist/proxy.js +41 -22
  54. package/dist/proxy.js.map +1 -1
  55. package/dist/token-usage.d.ts +9 -2
  56. package/dist/token-usage.js +20 -4
  57. package/dist/token-usage.js.map +1 -1
  58. package/dist/usage-client.d.ts +43 -13
  59. package/dist/usage-client.js +37 -54
  60. package/dist/usage-client.js.map +1 -1
  61. package/package.json +5 -4
  62. package/models.json +0 -1440
package/README.md CHANGED
@@ -52,44 +52,47 @@ Manage multiple API keys behind a single endpoint — OpenCode-Go **and** Comman
52
52
 
53
53
  ## Architecture
54
54
 
55
- ```
56
- ┌─────────────────────────────────────────────────────────────┐
57
- │ Your App / OpenCode │
58
- │ │
59
- │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
60
- │ │ Request 1│ │ Request 2│ │ Request 3│ ... │
61
- │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
62
- └────────┼──────────────┼──────────────┼──────────────────────┘
63
- │ │ │
64
- └──────────────┼──────────────┘
65
- │
66
- ▼
67
- ┌─────────────────────────────────────────────────────────────┐
68
- │ Saros (:3000) │
69
- │ │
70
- │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
71
- │ │ Key Selector │─▶│ Circuit │─▶│ Request │ │
72
- │ │ Round-Robin │ │ Breaker │ │ Forwarder │ │
73
- │ │ + Failover │ │ Threshold + │ │ + Timeout + │ │
74
- │ │ │ │ Cooldown │ │ SSE │ │
75
- │ └──────────────┘ └──────────────┘ └──────┬───────┘ │
76
- └─────────────────────────────────────────────┼───────────────┘
77
- │
78
- ┌───────────┼───────────┐
79
- ▼ ▼ ▼
80
- ┌──────────┐ ┌──────────┐ ┌──────────┐
81
- │ Key A │ │ Key B │ │ Key C │
82
- │ sk-abc… │ │ sk-def… │ │ user_… │
83
- └────┬─────┘ └────┬─────┘ └────┬─────┘
84
- │ │ │
85
- └────────────┼────────────┘
86
- │
87
- ┌─────────────────────┴─────────────────────┐
88
- ▼ ▼
89
- ┌───────────────────────┐ ┌──────────────────────────┐
90
- │ OpenCode-Go API │ │ CommandCode API │
91
- │ https://opencode.ai│ │ https://api.commandcode.ai│
92
- └───────────────────────┘ └──────────────────────────┘
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
93
96
  ```
94
97
 
95
98
  ### How Request Distribution Works
@@ -98,13 +101,46 @@ Here's how Saros picks which API key to use:
98
101
 
99
102
  #### Usage-Weighted Rotation
100
103
 
101
- Saros polls the upstream usage API for each key (5h / weekly / monthly windows)
102
- and weights rotation by **remaining** quota. If key X is 30% used and key Y is
103
- 70% used, 10 requests are distributed 7 → X and 3 → Y — proportional balancing,
104
- 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.
105
138
 
106
139
  - Refreshed in the background every 60s (stale data keeps working).
107
- - 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).
108
144
  - A key whose account has no plan for its provider (403 `EntitlementError` /
109
145
  `upgrade_required` from the usage endpoint) is dropped from rotation without
110
146
  tripping the circuit breaker, and returns as soon as a later refresh sees it
@@ -119,9 +155,9 @@ object for non-streaming bodies — is booked against the key that served the
119
155
  request. Totals are persisted to `~/.config/saros/token-usage.json` (debounced
120
156
  atomic writes) and shown by `saros-proxy usage`.
121
157
 
122
- This is the only consumption signal available for providers without a
123
- queryable quota API (CommandCode): their keys report no percentages, but
124
- `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.
125
161
 
126
162
  #### Circuit Breaker
127
163
 
@@ -140,11 +176,11 @@ Saros tracks which keys are currently in use. A key handling a streaming request
140
176
 
141
177
  ### Prompt Install(for the lazy ones)
142
178
 
143
- Don't feel like reading through everything? Copy this prompt and paste it to your AI assistant. It'll read [`LLM_INSTRUCTIONS.md`](LLM_INSTRUCTIONS.md) and guide you through setup — no keys shared in chat.
179
+ Don't feel like reading through everything? Copy this prompt and paste it to your AI assistant. It'll read [`docs/LLM_INSTRUCTIONS.md`](docs/LLM_INSTRUCTIONS.md) and guide you through setup — no keys shared in chat.
144
180
 
145
181
  ```
146
182
  Install Saros Proxy for my opencode. (https://github.com/vitorvilaca3011/saros-proxy)
147
- Follow the instructions on LLM_INSTRUCTIONS.md.
183
+ Follow the instructions on docs/LLM_INSTRUCTIONS.md.
148
184
  ```
149
185
 
150
186
  ---
@@ -374,7 +410,7 @@ OPENCODE_GO_KEYS="account1:sk-xxx,cc:user_yyy"
374
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.
375
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.
376
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`.
377
- - 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)).
378
414
 
379
415
  #### Smoke test (key identification)
380
416
 
@@ -445,14 +481,21 @@ into the "no passphrase source" boot error — a locked keyring is not reported
445
481
  as an empty store.
446
482
 
447
483
  **systemd-creds recipe** (keeps the passphrase out of the unit file and the
448
- environment of the shell):
484
+ environment of the shell). This is a **system** unit recipe — run the encryption
485
+ as root, since the host key lives in `/var/lib/systemd/credential.secret`:
449
486
 
450
487
  ```bash
451
- # One-time: encrypt stdin into a host+user-bound credential.
452
- systemd-creds encrypt --name=saros-passphrase --with-key=host+user \
488
+ # One-time, as root: encrypt stdin into a host-bound credential.
489
+ # (--with-key=auto picks tpm2+host when the TPM is available, host otherwise.
490
+ # "host+user" is not a valid key type, and --user only exists from v256.)
491
+ systemd-creds encrypt --name=saros-passphrase --with-key=auto \
453
492
  /dev/stdin /etc/saros/passphrase.cred
454
493
  ```
455
494
 
495
+ For the `systemd --user` unit created by `saros-proxy autostart install`, use
496
+ `Environment=`/`EnvironmentFile=` instead: user credentials need
497
+ `systemd-creds --user`, which only exists from systemd v256 (this host runs 255).
498
+
456
499
  ```ini
457
500
  # /etc/systemd/system/saros-proxy.service
458
501
  [Service]
@@ -555,16 +598,48 @@ saros-proxy probe
555
598
  # Show per-key quota usage, token totals and most-used models
556
599
  saros-proxy usage
557
600
 
558
- # Install autostart (Windows: VBS script or Registry)
559
- saros-proxy autostart install [--port <port>] [--method vbs|registry|auto]
601
+ # Install autostart (Linux: systemd --user; Windows: VBS script or Registry)
602
+ saros-proxy autostart install [--port <port>] [--method systemd|vbs|registry|auto]
560
603
 
561
604
  # Uninstall autostart
562
- saros-proxy autostart uninstall [--method vbs|registry|auto]
605
+ saros-proxy autostart uninstall [--method systemd|vbs|registry|auto]
563
606
 
564
607
  # Check autostart status
565
- saros-proxy autostart status [--method vbs|registry|auto]
608
+ saros-proxy autostart status [--method systemd|vbs|registry|auto]
609
+ ```
610
+
611
+ On Linux the default is a `systemd --user` unit at
612
+ `~/.config/systemd/user/saros-proxy.service` (honouring `$XDG_CONFIG_HOME`), enabled
613
+ with `systemctl --user enable` and restarted with `systemctl --user restart`. On
614
+ Windows the default is AV-aware (`auto`): Registry when third-party antivirus is
615
+ detected, otherwise the Startup-folder VBS.
616
+
617
+ Manage the unit with systemd, not with the CLI: `saros-proxy stop` exits non-zero
618
+ while the service still answers `/health`, because the CLI does not own the unit's
619
+ process.
620
+
621
+ ```bash
622
+ systemctl --user start|stop|restart|status saros-proxy.service
623
+ journalctl --user -u saros-proxy -f
566
624
  ```
567
625
 
626
+ The unit runs `serve` in the foreground: without a TTY it cannot prompt for a
627
+ passphrase, and the user manager does not inherit the environment of the shell
628
+ that ran `install`. If `config.yaml` holds `enc:v1:` envelopes, give the unit a
629
+ source through a drop-in (`systemctl --user edit saros-proxy.service`):
630
+
631
+ ```ini
632
+ [Service]
633
+ Environment=SAROS_PASSPHRASE=your-passphrase
634
+ ```
635
+
636
+ `EnvironmentFile=-%h/.config/saros/env` (mode 0600) keeps the value out of the
637
+ unit file. A credential is stronger, but `Environment=` can only carry the
638
+ credential *path* (`%d/<name>`), never its content — see the
639
+ [systemd-creds recipe](#api-key-encryption), which wraps `serve` in
640
+ `sh -c 'SAROS_PASSPHRASE="$(cat "$CREDENTIALS_DIRECTORY/<name>")" exec …'`.
641
+ `systemd-creds` only gained a `--user` mode in v256.
642
+
568
643
  The `start` command automatically syncs model definitions to all enabled harnesses (see [Multi-Harness Integration](#multi-harness-integration-pi-oh-my-pi--dsh)).
569
644
 
570
645
  ### Model Sync Commands
@@ -716,7 +791,7 @@ Behavior:
716
791
  - Sync never creates a harness config: harnesses whose config file is missing are skipped.
717
792
  - Only `providers["saros-proxy"].models` is replaced; all other providers, fields, and settings are preserved, and a `<file>.backup` is created before the first write.
718
793
 
719
- The model list written to each harness comes from the live provider catalogs (opencode-go) enriched with models.dev metadata, merged with any CommandCode-only models — falling back to bundled models when offline — so every harness stays current alongside OpenCode.
794
+ The model list written to each harness comes from the live provider catalogs (opencode-go) enriched with models.dev metadata, merged with any CommandCode-only models — falling back to the bundled `assets/models.json` when offline — so every harness stays current alongside OpenCode.
720
795
 
721
796
  > **Why a static model list?** The harnesses use a static `models` list on purpose. `pi` has no `/v1/models` discovery for custom providers, and `oh-my-pi`'s discovery cannot recover reasoning/context/output metadata for provider model IDs. `dsh` replaces only `providers["saros-proxy"].models` in its patch layer (`cordis.patch.yml`), which dsh rewrites at every boot — a static, explicitly-managed list is the only reliable source. Dynamic discovery would silently break thinking-level (`:max`/`:high`) role configs.
722
797
 
@@ -727,9 +802,9 @@ The model list written to each harness comes from the live provider catalogs (op
727
802
  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:
728
803
 
729
804
  - **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.
730
- - **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.
805
+ - **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.
731
806
 
732
- 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.
807
+ 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.
733
808
 
734
809
  ---
735
810
 
@@ -796,6 +871,7 @@ Disable it with `SAROS_DEBUG_BUFFER=0`: the endpoint answers `404` and no entry
796
871
  | `ECONNREFUSED` on startup | Port already in use | Change port or kill the existing process |
797
872
  | `504 Gateway Timeout` | Upstream too slow | Increase `requestTimeoutMs` |
798
873
  | `All API keys are temporarily unavailable` | All keys disabled by circuit breaker | Wait for cooldown or check upstream status |
874
+ | 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` |
799
875
  | CORS error | Client origin not allowed | Add it to `allowedOrigins` in config |
800
876
 
801
877
  ---
@@ -829,6 +905,18 @@ npm run build
829
905
 
830
906
  Output goes to `dist/`.
831
907
 
908
+ ### Refresh the bundled model catalog
909
+
910
+ `assets/models.json` is generated — do not edit it by hand. The generator merges models.dev metadata with the live opencode-go and CommandCode catalogs: a model that left upstream leaves the bundle, a model the upstream serves but models.dev does not list still enters with the live name and context window, and a field already in the file survives when models.dev omits it.
911
+
912
+ ```bash
913
+ npm run models:refresh -- --dry-run # print the diff, write nothing
914
+ npm run models:refresh # rewrite assets/models.json
915
+ npm run models:refresh -- --allow-shrink # accept removing more than half of the models
916
+ ```
917
+
918
+ A failed fetch — or a 2xx with an empty catalog — aborts before the file is read, and a merge that would remove more than half of the current models aborts unless `--allow-shrink` is given. The write goes through a temp file and an atomic rename, so a crash never leaves a truncated catalog.
919
+
832
920
  ### Project structure
833
921
 
834
922
  ```
@@ -842,7 +930,8 @@ src/
842
930
  providers/ — Multi-provider key abstraction (KeyProvider, adapters, identifyKey)
843
931
  models-fetcher.ts — Upstream model list fetching + caching
844
932
  models-sync.ts — Auto-sync models from upstream to opencode config
845
- models-catalog.ts — Single source of truth: loads bundled models.json
933
+ models-catalog.ts — Single source of truth: loads assets/models.json
934
+ models-refresh.ts — Generates that catalog (npm run models:refresh)
846
935
  cli/
847
936
  setup.ts — Interactive setup wizard
848
937
  daemon.ts — Background process management