proxitor 0.15.0 → 0.16.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 CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/proxitor"><img src="https://img.shields.io/npm/v/proxitor?color=6366f1&labelColor=1e2327&label=npm" alt="npm version"></a>
11
+ <a href="https://github.com/neiromaster/proxitor/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/neiromaster/proxitor/ci.yml?branch=main&color=22c55e&labelColor=1e2327&label=CI" alt="CI status"></a>
11
12
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-22c55e?labelColor=1e2327" alt="MIT License"></a>
12
13
  <img src="https://img.shields.io/badge/node-%3E%3D22-3b82f6?labelColor=1e2327" alt="Node.js ≥ 22">
13
14
  <a href="https://github.com/neiromaster/proxitor/issues"><img src="https://img.shields.io/github/issues/neiromaster/proxitor?color=f59e0b&labelColor=1e2327&label=issues" alt="GitHub issues"></a>
@@ -19,9 +20,34 @@
19
20
 
20
21
  ---
21
22
 
23
+ ## Contents
24
+
25
+ - [Why proxitor](#why-proxitor)
26
+ - [How it works](#how-it-works)
27
+ - [The caching problem](#the-caching-problem)
28
+ - [Features](#features)
29
+ - [Install](#install)
30
+ - [Quick start](#quick-start)
31
+ - [Minimal config](#minimal-config)
32
+ - [Configuration](#configuration)
33
+ - [Diagnostics](#diagnostics)
34
+ - [Commands](#commands)
35
+ - [Common pitfalls](#common-pitfalls)
36
+ - [Contributing](#contributing)
37
+ - [License](#license)
38
+
39
+ ## Why proxitor
40
+
41
+ AI CLIs already speak Anthropic or OpenAI APIs. proxitor keeps that interface intact and fixes the expensive parts in the middle:
42
+
43
+ - **Provider pinning** keeps OpenRouter from bouncing the same conversation between upstreams.
44
+ - **Prompt-cache shaping** adds sticky sessions, cache breakpoints, TTL fixes, and volatile-prefix normalization where needed.
45
+ - **Per-model routing** lets Claude, GPT, Qwen, GLM, and other model families use different providers and policies.
46
+ - **Operational checks** (`doctor`, `/health`, config validation, hot reload) make the proxy safe to leave running during long coding sessions.
47
+
22
48
  ## How it works
23
49
 
24
- ```
50
+ ```text
25
51
  your AI CLI → proxitor → OpenRouter → the provider you picked
26
52
  ```
27
53
 
@@ -33,6 +59,8 @@ OpenRouter load-balances across providers, and **prompt caching is provider-scop
33
59
 
34
60
  Pin `claude-*` to `anthropic`, and that system prompt gets cached after the first hit. Subsequent requests cost a fraction.
35
61
 
62
+ A typical 50k-token Claude Code system prompt at $3/M input costs **$0.15 per turn** with no cache. After a warm Anthropic cache, the same prefix costs ~10% of input price — about **$0.015 per turn**. The cache amortizes in 1-2 turns and pays for itself the rest of the session.
63
+
36
64
  ## Features
37
65
 
38
66
  - 🔒 **Stable caching** — pin models to a single provider so prompt caches survive across requests
@@ -48,6 +76,7 @@ Requires **Node.js 22+**.
48
76
 
49
77
  ```sh
50
78
  npm install -g proxitor
79
+ # or: pnpm install -g proxitor
51
80
  # or: bun install -g proxitor
52
81
  # or run it once, no install: npx proxitor
53
82
  ```
@@ -63,8 +92,9 @@ proxitor config wizard
63
92
  **2. Run it**
64
93
 
65
94
  ```sh
66
- proxitor
67
- # Listening on http://0.0.0.0:8828
95
+ proxitor # default: http://0.0.0.0:8828
96
+ proxitor --port 9000 # or pick a custom port
97
+ proxitor up # aliases: up, run
68
98
  ```
69
99
 
70
100
  **3. Point your tool at it**
@@ -79,6 +109,18 @@ OPENAI_BASE_URL=http://localhost:8828/v1 codex
79
109
 
80
110
  That's the whole setup. Requests flow through proxitor; streaming responses pass through untouched.
81
111
 
112
+ ## Minimal config
113
+
114
+ The wizard writes a full config; the minimum is just an API key and a routing rule. Drop this into `proxitor.config.yaml` (or `.yaml`/`.yml`/`.json`, also accepted as `.proxitor.yaml`/`.proxitor.json` in the project root):
115
+
116
+ ```yaml
117
+ openrouterKey: sk-or-v1-... # or set OPENROUTER_API_KEY in your shell
118
+ provider:
119
+ order: "anthropic" # pin everything to Anthropic for stable caching
120
+ ```
121
+
122
+ Run `proxitor config validate` to check it, then `proxitor` to start.
123
+
82
124
  ## Configuration
83
125
 
84
126
  The friendly way: an interactive menu — no YAML required.
@@ -95,6 +137,10 @@ From the menu you can set your API key and connection, pick routing per model (w
95
137
 
96
138
  Prefer to edit a file? The full **[configuration reference](./docs/configuration.md)** covers provider routing, per-model overrides, headers, caching modes, and every option. [`proxitor.config.example.yaml`](./proxitor.config.example.yaml) is a commented template.
97
139
 
140
+ **Hot-reload** — proxitor watches the config file and reloads on save; no restart needed. Bad edits fall back to the last valid config and the proxy keeps running. `proxitor config validate` shows the current state.
141
+
142
+ **Environment variables** — `OPENROUTER_API_KEY` is used when the config key is empty; `XDG_CONFIG_HOME` overrides the user-config directory on Linux/macOS. CLI flags take precedence over both.
143
+
98
144
  ## Diagnostics
99
145
 
100
146
  ```sh
@@ -105,7 +151,7 @@ It prints a clear report and exits non-zero if anything fails — handy from CI
105
151
 
106
152
  While proxitor runs, it logs cache usage from upstream so you can see whether caching is actually helping:
107
153
 
108
- ```
154
+ ```text
109
155
  [abc123] Cache read: 50000, write: 25000 tokens (99.6% hit)
110
156
  ```
111
157
 
@@ -113,7 +159,7 @@ Quick health poke: `curl http://localhost:8828/health`.
113
159
 
114
160
  ### Tuning the cache
115
161
 
116
- If the cache hit looks low, three levers fix it — tune them from `proxitor config` → **💾 Caching** (or `proxitor config cache`):
162
+ If the cache hit looks low, four levers fix it — tune them from `proxitor config` → **💾 Caching** (or `proxitor config cache`):
117
163
 
118
164
  - **`cacheControl`** — inject `cache_control` to activate caching (Anthropic-native).
119
165
  - **`sessionId`** — inject `session_id` so the provider pins from the first request.
@@ -125,15 +171,33 @@ See the [configuration reference](./docs/configuration.md#prompt-caching) for th
125
171
  ## Commands
126
172
 
127
173
  | Command | Description |
128
- |---|---|
174
+ | --- | --- |
129
175
  | `proxitor` | Start the proxy (default command) |
130
176
  | `proxitor config` | Interactive config menu |
131
177
  | `proxitor config wizard` | Guided setup |
132
178
  | `proxitor config browse` | Explore models + pricing |
179
+ | `proxitor config add` | Add a model override |
180
+ | `proxitor config edit` | Edit an existing model override |
181
+ | `proxitor config remove` | Remove a model override |
182
+ | `proxitor config list` | List all model overrides (also `--json`) |
183
+ | `proxitor config cache` | Tune prompt-caching settings |
184
+ | `proxitor config show` | Print the resolved config |
185
+ | `proxitor config validate` | Check the config (exit 0 ok, 1 invalid — CI-friendly) |
133
186
  | `proxitor doctor` | Diagnose everything |
187
+ | `proxitor --version` | Print version |
134
188
  | `proxitor --help` | Full list of flags |
135
189
 
136
- Common flags: `--port`, `--host`, `--config <path>`, `--openrouter-key <key>`.
190
+ Common flags: `--port`, `--host`, `--config <path>`, `--openrouter-key <key>` / `-k <key>`, `--verbose`, `--no-config`.
191
+
192
+ ## Common pitfalls
193
+
194
+ **Cache reads stay at 0 even after several requests.** The prefix usually churns every turn (Claude Code's `cch`/`cc_version` hashes) — enable `normalizeVolatileSystem: true` and confirm the request actually lands on the same provider. `proxitor doctor` reports the loaded config; the cache-read log in the proxy console reports hits.
195
+
196
+ **Anthropic returns `400` about mixed TTLs when `cacheControlTtl: 1h`.** Set `rewriteBlockTtl: auto` (or `always`) to normalize the client's block-level `cache_control` breakpoints to the same TTL — see the [configuration reference](./docs/configuration.md#prompt-caching).
197
+
198
+ **The provider keeps switching between requests.** Make sure `sessionId` is not `skip` — both `auto` (default) and `always` inject a sticky session ID; without it OpenRouter only pins after the first cache hit.
199
+
200
+ **Config edits don't take effect.** They should — proxitor hot-reloads on save. If the file is invalid the proxy keeps the last valid config; `proxitor config validate` shows what was rejected.
137
201
 
138
202
  ## Contributing
139
203
 
package/dist/cli.mjs CHANGED
@@ -16329,6 +16329,16 @@ var MissingConfigError = class extends Error {
16329
16329
  }
16330
16330
  };
16331
16331
  //#endregion
16332
+ //#region src/model-id.ts
16333
+ /** `"anthropic/claude-sonnet-4"` → `"anthropic"`; a bare `"gpt-4o"` → `"gpt-4o"`. */
16334
+ function parseModelAuthor(modelId) {
16335
+ return modelId.split("/")[0] ?? "";
16336
+ }
16337
+ /** Text after the first `/`, or `""` for a bare id. `"openai/gpt-4o"` → `"gpt-4o"`. */
16338
+ function parseModelSlug(modelId) {
16339
+ return modelId.split("/").slice(1).join("/");
16340
+ }
16341
+ //#endregion
16332
16342
  //#region src/utils.ts
16333
16343
  /** Format an API key with the appropriate auth prefix based on authType. Defaults to Bearer. */
16334
16344
  function formatAuthHeader(key, authType) {
@@ -16411,14 +16421,49 @@ function buildProviderRouting(provider) {
16411
16421
  if (result.order) result.allow_fallbacks = provider.allowFallbacks ?? true;
16412
16422
  return Object.keys(result).length > 0 ? result : void 0;
16413
16423
  }
16424
+ /** Slug after the vendor prefix; bare ids keep their text. Delegates to parseModelSlug. */
16425
+ function modelSlug(s) {
16426
+ return parseModelSlug(s) || s;
16427
+ }
16414
16428
  function matchScore(pattern, modelName) {
16415
- if (pattern === modelName) return modelName.length + 1e3;
16416
- if (pattern.endsWith("*") && modelName.startsWith(pattern.slice(0, -1))) return pattern.length;
16429
+ if (pattern === modelName) return 3e3 + pattern.length;
16430
+ if (pattern.endsWith("*") && modelName.startsWith(pattern.slice(0, -1))) return 2e3 + pattern.length;
16431
+ if (pattern.includes("/") && modelName.includes("/")) return -1;
16432
+ const sp = modelSlug(pattern);
16433
+ const sm = modelSlug(modelName);
16434
+ if (sp === sm) return 1e3 + sp.length;
16435
+ if (sp.endsWith("*") && sm.startsWith(sp.slice(0, -1))) return sp.length;
16417
16436
  return -1;
16418
16437
  }
16419
16438
  function matchesPattern(pattern, modelName) {
16420
16439
  return matchScore(pattern, modelName) >= 0;
16421
16440
  }
16441
+ /** Override keys sharing a slug. `winner` is the key a bare name resolves to — not always `keys[0]`, since a bare key wins on the full-exact tier. Pure; callers log it. */
16442
+ function detectSlugCollisions(overrides) {
16443
+ if (!overrides) return [];
16444
+ const groups = /* @__PURE__ */ new Map();
16445
+ for (const key of Object.keys(overrides)) {
16446
+ const slug = modelSlug(key);
16447
+ const keys = groups.get(slug);
16448
+ if (keys) keys.push(key);
16449
+ else groups.set(slug, [key]);
16450
+ }
16451
+ const collisions = [];
16452
+ for (const [slug, keys] of groups) {
16453
+ if (keys.length <= 1) continue;
16454
+ const winner = findBestMatch(keys, slug);
16455
+ if (winner) collisions.push({
16456
+ slug,
16457
+ keys,
16458
+ winner
16459
+ });
16460
+ }
16461
+ return collisions;
16462
+ }
16463
+ /** Warning text for a same-slug collision. */
16464
+ function formatSlugCollisionWarning(c) {
16465
+ return `Overrides ${c.keys.map((k) => `"${k}"`).join(" and ")} share model slug "${c.slug}"; a bare name resolves to "${c.winner}". Use the vendor-prefixed name to pick a specific one.`;
16466
+ }
16422
16467
  function resolveModelConfig(config, modelName) {
16423
16468
  const result = {
16424
16469
  provider: config.provider,
@@ -16431,7 +16476,10 @@ function resolveModelConfig(config, modelName) {
16431
16476
  };
16432
16477
  if (!modelName || !config.modelOverrides) return result;
16433
16478
  const bestPattern = findBestMatch(Object.keys(config.modelOverrides), modelName);
16434
- if (bestPattern) applyOverride(result, config.modelOverrides[bestPattern]);
16479
+ if (bestPattern) {
16480
+ applyOverride(result, config.modelOverrides[bestPattern]);
16481
+ result.matchedOverride = bestPattern;
16482
+ }
16435
16483
  return result;
16436
16484
  }
16437
16485
  function findBestMatch(patterns, modelName) {
@@ -16677,14 +16725,6 @@ async function fetchModels(client) {
16677
16725
  writeCache(CACHE_KEY$1, models);
16678
16726
  return models;
16679
16727
  }
16680
- /** `"anthropic/claude-sonnet-4"` → `"anthropic"` */
16681
- function parseModelAuthor(modelId) {
16682
- return modelId.split("/")[0] ?? "";
16683
- }
16684
- /** `"anthropic/claude-sonnet-4"` → `"claude-sonnet-4"` */
16685
- function parseModelSlug(modelId) {
16686
- return modelId.split("/").slice(1).join("/");
16687
- }
16688
16728
  /** `"0.000003"` → `"$3.00"`, `"0"` → `"free"` */
16689
16729
  function formatPrice(pricePerToken) {
16690
16730
  const per1M = Number.parseFloat(pricePerToken) * 1e6;
@@ -19514,7 +19554,7 @@ async function runConfigMenu(client) {
19514
19554
  }
19515
19555
  //#endregion
19516
19556
  //#region src/version.ts
19517
- const version = "0.15.0";
19557
+ const version = "0.16.0";
19518
19558
  //#endregion
19519
19559
  //#region src/commands/doctor.ts
19520
19560
  const DEFAULT_TIMEOUT_MS = 3e3;
@@ -19601,6 +19641,24 @@ function checkConfigValidity(path) {
19601
19641
  };
19602
19642
  }
19603
19643
  }
19644
+ function checkSlugCollisions(cfg) {
19645
+ if (!cfg?.modelOverrides) return {
19646
+ name: "override-collisions",
19647
+ status: "ok",
19648
+ message: "no model overrides"
19649
+ };
19650
+ const collisions = detectSlugCollisions(cfg.modelOverrides);
19651
+ if (collisions.length === 0) return {
19652
+ name: "override-collisions",
19653
+ status: "ok",
19654
+ message: "no slug collisions"
19655
+ };
19656
+ return {
19657
+ name: "override-collisions",
19658
+ status: "warn",
19659
+ message: collisions.map(formatSlugCollisionWarning).join(" | ")
19660
+ };
19661
+ }
19604
19662
  function checkApiKey(cfg) {
19605
19663
  const fromEnv = process.env.OPENROUTER_API_KEY ? "set" : "not set";
19606
19664
  const fromFile = cfg?.openrouterKey ? "set" : "not set";
@@ -19709,7 +19767,11 @@ async function doctorCommand(opts = {}) {
19709
19767
  ];
19710
19768
  const configPath = tryFindConfigFile();
19711
19769
  const { check: validityCheck, config: cfg } = checkConfigValidity(configPath);
19712
- const configChecks = [checkConfigDiscovery(), validityCheck];
19770
+ const configChecks = [
19771
+ checkConfigDiscovery(),
19772
+ validityCheck,
19773
+ checkSlugCollisions(cfg)
19774
+ ];
19713
19775
  const apiKeyCheck = checkApiKey(cfg);
19714
19776
  const upstreamCheck = await checkUpstream(cfg, timeoutMs, offline);
19715
19777
  const portCheck = cfg ? await checkPort(cfg.host, cfg.port) : {
@@ -19835,8 +19897,10 @@ var FileWatchingConfigSource = class {
19835
19897
  loading = false;
19836
19898
  pending = false;
19837
19899
  watching = false;
19900
+ lastCollisionSig = "";
19838
19901
  constructor(options) {
19839
19902
  this.current = options.initial;
19903
+ this.warnSlugCollisions(options.initial.modelOverrides);
19840
19904
  this.loadOptions = options.loadOptions;
19841
19905
  this.load = options.load ?? loadConfig;
19842
19906
  this.pollIntervalMs = options.pollIntervalMs ?? 1e3;
@@ -19848,6 +19912,18 @@ var FileWatchingConfigSource = class {
19848
19912
  get() {
19849
19913
  return this.current;
19850
19914
  }
19915
+ /** Warn only when the collision set changes, so reloading an unchanged config doesn't re-log. */
19916
+ warnSlugCollisions(overrides) {
19917
+ const collisions = detectSlugCollisions(overrides ?? void 0);
19918
+ const sig = collisions.map((c) => [
19919
+ c.slug,
19920
+ c.winner,
19921
+ [...c.keys].sort((a, b) => a.localeCompare(b)).join(",")
19922
+ ].join("|")).sort((a, b) => a.localeCompare(b)).join("||");
19923
+ if (sig === this.lastCollisionSig) return;
19924
+ this.lastCollisionSig = sig;
19925
+ for (const collision of collisions) logger.warn(formatSlugCollisionWarning(collision));
19926
+ }
19851
19927
  async reload() {
19852
19928
  if (this.loading) {
19853
19929
  this.pending = true;
@@ -19864,6 +19940,7 @@ var FileWatchingConfigSource = class {
19864
19940
  diff = "";
19865
19941
  }
19866
19942
  this.current = next;
19943
+ this.warnSlugCollisions(next.modelOverrides);
19867
19944
  if (restartNeeded) logger.warn("host/port changed — restart proxitor to apply (live reload does not re-bind the socket)");
19868
19945
  logger.info(`Config reloaded${diff ? ` — ${diff}` : " (no material changes)"}`);
19869
19946
  return { ok: true };
@@ -23340,6 +23417,7 @@ const readBody = createMiddleware(async (c, next) => {
23340
23417
  const resolveConfig = createMiddleware(async (c, next) => {
23341
23418
  const resolved = resolveModelConfig(c.var.config, c.var.modelName);
23342
23419
  c.set("resolvedConfig", resolved);
23420
+ if (c.var.config.verbose && c.var.modelName) logger.info(resolved.matchedOverride ? `override "${resolved.matchedOverride}" matched incoming "${c.var.modelName}"` : `no override matched for "${c.var.modelName}"`);
23343
23421
  await next();
23344
23422
  });
23345
23423
  //#endregion