context-doctor 0.17.0 → 0.18.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 +14 -1
- package/dist/cli.js +7 -0
- package/dist/mcp.js +1 -1
- package/dist/proxy.d.ts +15 -0
- package/dist/proxy.js +39 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -179,7 +179,20 @@ The proxy dedupes repeated content, trims stale tool results, and strips base64
|
|
|
179
179
|
|
|
180
180
|
Because prompt caching matches byte-identical prefixes, deterministic strategies are chosen so repeated requests stay stable — but if you rely on aggressive cache prefixes, start with `--strategy strip-base64 --strategy dedupe` and add more as you verify.
|
|
181
181
|
|
|
182
|
-
|
|
182
|
+
### Putting the proxy on a public URL (Cursor with your own OpenAI key, remote apps)
|
|
183
|
+
|
|
184
|
+
Some apps let you set a base URL but call it from *their* servers, not your machine. Cursor is one: with your own OpenAI key, "Override OpenAI Base URL" is sent to Cursor's backend inside the model configuration, and Cursor's servers make the request (only the key-verification ping is client-side; we checked the app bundle, 3.18.25). So `127.0.0.1` cannot work there; the proxy has to be reachable from the internet, and an open relay on the internet is a bad idea. Hence the token:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
npx context-doctor proxy --token "$(openssl rand -hex 16)" # or CONTEXT_DOCTOR_PROXY_TOKEN=...
|
|
188
|
+
ngrok http 8787 # or any HTTPS tunnel / reverse proxy
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
With `--token`, every path except `/health` must start with `/t/<token>/`; anything else gets 401 before any upstream call, and the comparison is constant time. Then in Cursor: Settings > Models > OpenAI API Key > Override OpenAI Base URL = `https://<your-host>/t/<token>/v1`. Every agent request Cursor makes with your key now passes through the proxy: deduped, stale tool results trimmed, base64 stripped, real usage counted in `/t/<token>/stats`. This is the one Cursor path that is model-independent and needs no hook. It applies only to BYO-key traffic; Cursor's own subscription models never leave Cursor's servers.
|
|
192
|
+
|
|
193
|
+
Your API key still rides in the request headers, as before. The token protects the relay, not the key; keep the tunnel HTTPS.
|
|
194
|
+
|
|
195
|
+
> **Note on desktop chat apps:** Claude Desktop and the ChatGPT app talk to their own backends — no tool can sit in that path. For those, use the MCP integration below (Claude Desktop gets the standing rules and a cheap `profile_context` sketch call) and `context-doctor instructions --copy` for the per-account preferences.
|
|
183
196
|
|
|
184
197
|
## Use with the Claude & ChatGPT apps
|
|
185
198
|
|
package/dist/cli.js
CHANGED
|
@@ -110,6 +110,9 @@ Options:
|
|
|
110
110
|
overwrites a statusLine you already have)
|
|
111
111
|
--port <n> (proxy) Port to listen on (default 8787)
|
|
112
112
|
--host <addr> (proxy) Bind address (default 127.0.0.1; use 0.0.0.0 to expose)
|
|
113
|
+
--token <secret> (proxy) Require /t/<secret>/ in every request path; needed before
|
|
114
|
+
putting the proxy on a public URL (Cursor BYO-key, tunnels).
|
|
115
|
+
Also read from CONTEXT_DOCTOR_PROXY_TOKEN
|
|
113
116
|
--config <file> (proxy) Per-route overrides: {"routes":[{"modelPrefix":"gpt","strategies":[...],
|
|
114
117
|
"keepRecent":n,"maxToolResultTokens":n}]} — first prefix match wins
|
|
115
118
|
--upstream-anthropic <url> (proxy) Override Anthropic upstream (testing)
|
|
@@ -199,6 +202,9 @@ function parseArgs(argv) {
|
|
|
199
202
|
case "--host":
|
|
200
203
|
args.host = argv[++i];
|
|
201
204
|
break;
|
|
205
|
+
case "--token":
|
|
206
|
+
args.token = argv[++i];
|
|
207
|
+
break;
|
|
202
208
|
case "--config":
|
|
203
209
|
args.config = argv[++i];
|
|
204
210
|
break;
|
|
@@ -461,6 +467,7 @@ function main() {
|
|
|
461
467
|
routes: routes,
|
|
462
468
|
port: args.port,
|
|
463
469
|
host: args.host,
|
|
470
|
+
token: args.token ?? process.env.CONTEXT_DOCTOR_PROXY_TOKEN,
|
|
464
471
|
anthropicUpstream: args.upstreamAnthropic,
|
|
465
472
|
openaiUpstream: args.upstreamOpenai,
|
|
466
473
|
strategies: args.strategies.length > 0 ? args.strategies : loadedRc.config.strategies,
|
package/dist/mcp.js
CHANGED
|
@@ -49,7 +49,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
|
|
|
49
49
|
* recommended pattern.
|
|
50
50
|
*/
|
|
51
51
|
function createServer() {
|
|
52
|
-
const server = new McpServer({ name: "context-doctor", version: "0.
|
|
52
|
+
const server = new McpServer({ name: "context-doctor", version: "0.18.0" }, { instructions: SERVER_INSTRUCTIONS });
|
|
53
53
|
server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown, largest blocks, and actionable findings about wasted context (duplicates, oversized pastes or tool results, base64 blobs, long history). Two inputs, pass ONE: `conversation` (full OpenAI/Anthropic JSON or raw text, for agents, files and proxies) or `sketch` (for chat apps such as Claude Desktop or ChatGPT where you cannot export the conversation: the turn count plus the few blocks that matter, ~100 tokens to write). Call it whenever the user asks about token usage, context size, cost, speed or limits, and on your own once the conversation passes ~30 turns or holds 3+ large pastes. Act on the top finding in your reply.", {
|
|
54
54
|
conversation: z.string().optional().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text. Omit in chat apps and pass `sketch`."),
|
|
55
55
|
sketch: z.object({
|
package/dist/proxy.d.ts
CHANGED
|
@@ -23,9 +23,24 @@ export interface ProxyOptions extends OptimizeOptions {
|
|
|
23
23
|
* the user explicitly opts in (e.g. --host 0.0.0.0 inside a container).
|
|
24
24
|
*/
|
|
25
25
|
host?: string;
|
|
26
|
+
/**
|
|
27
|
+
* When set, every request except /health must arrive under the path prefix
|
|
28
|
+
* `/t/<token>/`, which is stripped before routing. This is what makes the
|
|
29
|
+
* proxy safe to put on a public URL (a tunnel) for apps whose servers call
|
|
30
|
+
* the base URL, such as Cursor with your own OpenAI key: those apps can set a
|
|
31
|
+
* URL but not a header, so the secret rides in the path. Compared with
|
|
32
|
+
* constant time; a wrong or missing prefix gets 401 and no upstream call.
|
|
33
|
+
*/
|
|
34
|
+
token?: string;
|
|
26
35
|
anthropicUpstream?: string;
|
|
27
36
|
openaiUpstream?: string;
|
|
28
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Remove a leading `/t/<token>` from a request path, or return undefined when
|
|
40
|
+
* the prefix is absent or the token differs. The comparison is constant time
|
|
41
|
+
* so the token cannot be guessed a character at a time.
|
|
42
|
+
*/
|
|
43
|
+
export declare function stripToken(url: string, token: string): string | undefined;
|
|
29
44
|
export interface ProxyStats {
|
|
30
45
|
startedAt: string;
|
|
31
46
|
requests: number;
|
package/dist/proxy.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* Streaming responses are piped through unchanged.
|
|
13
13
|
*/
|
|
14
14
|
import http from "node:http";
|
|
15
|
+
import { timingSafeEqual } from "node:crypto";
|
|
15
16
|
import { optimizeConversation } from "./optimize.js";
|
|
16
17
|
import { formatTokens } from "./tokens.js";
|
|
17
18
|
import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
|
|
@@ -19,6 +20,23 @@ import { recordLedger } from "./ledger.js";
|
|
|
19
20
|
/** Connection-level headers that must not be forwarded. */
|
|
20
21
|
const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "expect"]);
|
|
21
22
|
const SKIP_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
|
|
23
|
+
/**
|
|
24
|
+
* Remove a leading `/t/<token>` from a request path, or return undefined when
|
|
25
|
+
* the prefix is absent or the token differs. The comparison is constant time
|
|
26
|
+
* so the token cannot be guessed a character at a time.
|
|
27
|
+
*/
|
|
28
|
+
export function stripToken(url, token) {
|
|
29
|
+
const prefix = "/t/";
|
|
30
|
+
if (!url.startsWith(prefix))
|
|
31
|
+
return undefined;
|
|
32
|
+
const end = url.indexOf("/", prefix.length);
|
|
33
|
+
const candidate = end === -1 ? url.slice(prefix.length) : url.slice(prefix.length, end);
|
|
34
|
+
const a = Buffer.from(candidate), b = Buffer.from(token);
|
|
35
|
+
if (a.length !== b.length || !timingSafeEqual(a, b))
|
|
36
|
+
return undefined;
|
|
37
|
+
const rest = end === -1 ? "/" : url.slice(end);
|
|
38
|
+
return rest;
|
|
39
|
+
}
|
|
22
40
|
function upstreamFor(url, opts) {
|
|
23
41
|
if (url.startsWith("/v1/messages"))
|
|
24
42
|
return opts.anthropicUpstream ?? "https://api.anthropic.com";
|
|
@@ -75,13 +93,23 @@ export function startProxy(opts = {}) {
|
|
|
75
93
|
console.error(`[context-doctor] cache advisor: ${msg}`);
|
|
76
94
|
};
|
|
77
95
|
const server = http.createServer(async (req, res) => {
|
|
78
|
-
|
|
96
|
+
let url = req.url ?? "/";
|
|
79
97
|
try {
|
|
80
98
|
if (url === "/health") {
|
|
81
99
|
res.setHeader("content-type", "application/json");
|
|
82
100
|
res.end(JSON.stringify({ ok: true, service: "context-doctor-proxy" }));
|
|
83
101
|
return;
|
|
84
102
|
}
|
|
103
|
+
if (opts.token) {
|
|
104
|
+
const stripped = stripToken(url, opts.token);
|
|
105
|
+
if (stripped === undefined) {
|
|
106
|
+
res.statusCode = 401;
|
|
107
|
+
res.setHeader("content-type", "application/json");
|
|
108
|
+
res.end(JSON.stringify({ error: "context-doctor proxy: this proxy requires its token in the path: /t/<token>/v1/..." }));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
url = stripped;
|
|
112
|
+
}
|
|
85
113
|
if (url === "/stats") {
|
|
86
114
|
res.setHeader("content-type", "application/json");
|
|
87
115
|
res.end(JSON.stringify({ ...stats, estUsdSaved: Number(stats.estUsdSaved.toFixed(4)) }, null, 2));
|
|
@@ -278,11 +306,18 @@ export function startProxy(opts = {}) {
|
|
|
278
306
|
server.on("close", checkpoint);
|
|
279
307
|
const host = opts.host ?? "127.0.0.1";
|
|
280
308
|
server.listen(port, host, () => {
|
|
309
|
+
// Print the token as <token>, never the value: this log is what people paste into bug reports.
|
|
310
|
+
const prefix = opts.token ? "/t/<token>" : "";
|
|
281
311
|
console.error(`context-doctor proxy listening on http://${host}:${port}`);
|
|
282
|
-
console.error(` Anthropic apps/SDKs: export ANTHROPIC_BASE_URL=http://localhost:${port}`);
|
|
283
|
-
console.error(` OpenAI apps/SDKs: export OPENAI_BASE_URL=http://localhost:${port}/v1`);
|
|
312
|
+
console.error(` Anthropic apps/SDKs: export ANTHROPIC_BASE_URL=http://localhost:${port}${prefix}`);
|
|
313
|
+
console.error(` OpenAI apps/SDKs: export OPENAI_BASE_URL=http://localhost:${port}${prefix}/v1`);
|
|
284
314
|
console.error(` Every request's context is optimized in flight; savings are logged here.`);
|
|
285
|
-
console.error(` Cumulative savings: http://localhost:${port}/stats`);
|
|
315
|
+
console.error(` Cumulative savings: http://localhost:${port}${prefix}/stats`);
|
|
316
|
+
if (opts.token) {
|
|
317
|
+
console.error(` Token required: every path except /health must start with /t/<token>/.`);
|
|
318
|
+
console.error(` Cursor with your own OpenAI key: expose this port on HTTPS (a tunnel), then Settings > Models > OpenAI API Key >`);
|
|
319
|
+
console.error(` "Override OpenAI Base URL" = https://<your-host>/t/<token>/v1. Cursor's servers call that URL, so 127.0.0.1 will not work there.`);
|
|
320
|
+
}
|
|
286
321
|
});
|
|
287
322
|
return server;
|
|
288
323
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Profile and optimize LLM context windows. See what's eating your tokens and fix it — works with Claude Code, Claude Desktop, Cursor, Codex (OpenAI), and any MCP-capable AI app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|