mtrx-cli 0.1.8 → 0.1.10
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/package.json +3 -1
- package/src/matrx/__init__.py +1 -1
- package/src/matrx/cli/cursor_config.py +331 -0
- package/src/matrx/cli/cursor_proxy.py +351 -0
- package/src/matrx/cli/launcher.py +160 -8
- package/src/matrx/cli/main.py +278 -12
- package/src/matrx/cli/state.py +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mtrx-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "MATRX CLI for routing Codex and Claude through Matrx",
|
|
5
5
|
"homepage": "https://mtrx.so",
|
|
6
6
|
"repository": {
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"bin/mtrx.js",
|
|
25
25
|
"src/matrx/__init__.py",
|
|
26
26
|
"src/matrx/cli/__init__.py",
|
|
27
|
+
"src/matrx/cli/cursor_config.py",
|
|
28
|
+
"src/matrx/cli/cursor_proxy.py",
|
|
27
29
|
"src/matrx/cli/launcher.py",
|
|
28
30
|
"src/matrx/cli/main.py",
|
|
29
31
|
"src/matrx/cli/state.py"
|
package/src/matrx/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.
|
|
1
|
+
__version__ = "0.1.10"
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read / write Cursor IDE settings stored in the SQLite ``state.vscdb`` database.
|
|
3
|
+
|
|
4
|
+
Cursor stores its model-related settings (custom API keys, base URL overrides)
|
|
5
|
+
in ``~/Library/Application Support/Cursor/User/globalStorage/state.vscdb``
|
|
6
|
+
(macOS) inside an ``ItemTable`` key-value store.
|
|
7
|
+
|
|
8
|
+
This module provides helpers to:
|
|
9
|
+
- locate the database on each platform
|
|
10
|
+
- discover which keys Cursor uses for the OpenAI API key / base URL override
|
|
11
|
+
- read, write, backup, and restore those settings
|
|
12
|
+
- print manual setup instructions as a fallback
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import sqlite3
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Known key patterns that Cursor uses (or has used) for BYOK settings.
|
|
27
|
+
# These are discovered empirically; Cursor may change them across versions.
|
|
28
|
+
_CANDIDATE_SETTINGS_KEYS = [
|
|
29
|
+
"storage.ServiceStorage",
|
|
30
|
+
"cursorai/cachedSettings",
|
|
31
|
+
"cursorai/modelSettings",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_OPENAI_KEY_FIELD = "openaiApiKey"
|
|
35
|
+
_OPENAI_BASE_URL_FIELD = "openaiBaseUrl"
|
|
36
|
+
_OVERRIDE_OPENAI_BASE_URL_FIELD = "enableOpenaiBaseUrlOverride"
|
|
37
|
+
_ANTHROPIC_KEY_FIELD = "anthropicApiKey"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cursor_state_db_path() -> Path:
|
|
41
|
+
"""Return the platform-specific path to Cursor's ``state.vscdb``."""
|
|
42
|
+
system = platform.system()
|
|
43
|
+
if system == "Darwin":
|
|
44
|
+
return (
|
|
45
|
+
Path.home()
|
|
46
|
+
/ "Library"
|
|
47
|
+
/ "Application Support"
|
|
48
|
+
/ "Cursor"
|
|
49
|
+
/ "User"
|
|
50
|
+
/ "globalStorage"
|
|
51
|
+
/ "state.vscdb"
|
|
52
|
+
)
|
|
53
|
+
if system == "Linux":
|
|
54
|
+
return (
|
|
55
|
+
Path.home()
|
|
56
|
+
/ ".config"
|
|
57
|
+
/ "Cursor"
|
|
58
|
+
/ "User"
|
|
59
|
+
/ "globalStorage"
|
|
60
|
+
/ "state.vscdb"
|
|
61
|
+
)
|
|
62
|
+
# Windows
|
|
63
|
+
appdata = os.environ.get("APPDATA", "")
|
|
64
|
+
if appdata:
|
|
65
|
+
return Path(appdata) / "Cursor" / "User" / "globalStorage" / "state.vscdb"
|
|
66
|
+
return Path.home() / "AppData" / "Roaming" / "Cursor" / "User" / "globalStorage" / "state.vscdb"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cursor_is_running() -> bool:
|
|
70
|
+
"""Best-effort check for whether a Cursor process is active."""
|
|
71
|
+
system = platform.system()
|
|
72
|
+
if system == "Darwin":
|
|
73
|
+
import subprocess
|
|
74
|
+
try:
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
["pgrep", "-f", "Cursor"],
|
|
77
|
+
capture_output=True,
|
|
78
|
+
timeout=3,
|
|
79
|
+
)
|
|
80
|
+
return result.returncode == 0
|
|
81
|
+
except Exception:
|
|
82
|
+
return False
|
|
83
|
+
if system == "Linux":
|
|
84
|
+
import subprocess
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
["pgrep", "-f", "[Cc]ursor"],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
timeout=3,
|
|
90
|
+
)
|
|
91
|
+
return result.returncode == 0
|
|
92
|
+
except Exception:
|
|
93
|
+
return False
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _open_db(db_path: Path) -> sqlite3.Connection | None:
|
|
98
|
+
if not db_path.exists():
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
conn = sqlite3.connect(str(db_path), timeout=5)
|
|
102
|
+
return conn
|
|
103
|
+
except sqlite3.Error as exc:
|
|
104
|
+
logger.debug("cursor_config: cannot open %s: %s", db_path, exc)
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _find_settings_key(conn: sqlite3.Connection) -> str | None:
|
|
109
|
+
"""Discover which ItemTable key holds the model/BYOK settings blob."""
|
|
110
|
+
cursor = conn.cursor()
|
|
111
|
+
for candidate in _CANDIDATE_SETTINGS_KEYS:
|
|
112
|
+
cursor.execute(
|
|
113
|
+
"SELECT value FROM ItemTable WHERE key = ?", (candidate,)
|
|
114
|
+
)
|
|
115
|
+
row = cursor.fetchone()
|
|
116
|
+
if row is None:
|
|
117
|
+
continue
|
|
118
|
+
try:
|
|
119
|
+
data = json.loads(row[0]) if isinstance(row[0], str) else row[0]
|
|
120
|
+
except (json.JSONDecodeError, TypeError):
|
|
121
|
+
continue
|
|
122
|
+
if isinstance(data, dict) and (
|
|
123
|
+
_OPENAI_KEY_FIELD in data
|
|
124
|
+
or _OPENAI_BASE_URL_FIELD in data
|
|
125
|
+
or _OVERRIDE_OPENAI_BASE_URL_FIELD in data
|
|
126
|
+
):
|
|
127
|
+
return candidate
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _scan_for_settings_key(conn: sqlite3.Connection) -> str | None:
|
|
132
|
+
"""Fallback: scan all keys looking for any JSON blob with openai fields."""
|
|
133
|
+
cursor = conn.cursor()
|
|
134
|
+
cursor.execute("SELECT key, value FROM ItemTable")
|
|
135
|
+
for key, value in cursor.fetchall():
|
|
136
|
+
if not isinstance(value, str):
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
data = json.loads(value)
|
|
140
|
+
except (json.JSONDecodeError, TypeError):
|
|
141
|
+
continue
|
|
142
|
+
if isinstance(data, dict) and (
|
|
143
|
+
_OPENAI_KEY_FIELD in data
|
|
144
|
+
or _OPENAI_BASE_URL_FIELD in data
|
|
145
|
+
or _OVERRIDE_OPENAI_BASE_URL_FIELD in data
|
|
146
|
+
or _ANTHROPIC_KEY_FIELD in data
|
|
147
|
+
):
|
|
148
|
+
return key
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def read_cursor_settings(db_path: Path | None = None) -> dict | None:
|
|
153
|
+
"""
|
|
154
|
+
Read Cursor's current BYOK/model settings.
|
|
155
|
+
|
|
156
|
+
Returns the parsed JSON blob, or None if the DB cannot be read or the
|
|
157
|
+
settings key is not found.
|
|
158
|
+
"""
|
|
159
|
+
db_path = db_path or cursor_state_db_path()
|
|
160
|
+
conn = _open_db(db_path)
|
|
161
|
+
if conn is None:
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
settings_key = _find_settings_key(conn) or _scan_for_settings_key(conn)
|
|
165
|
+
if not settings_key:
|
|
166
|
+
return None
|
|
167
|
+
cursor = conn.cursor()
|
|
168
|
+
cursor.execute(
|
|
169
|
+
"SELECT value FROM ItemTable WHERE key = ?", (settings_key,)
|
|
170
|
+
)
|
|
171
|
+
row = cursor.fetchone()
|
|
172
|
+
if row is None:
|
|
173
|
+
return None
|
|
174
|
+
data = json.loads(row[0]) if isinstance(row[0], str) else {}
|
|
175
|
+
data["_mtrx_settings_key"] = settings_key
|
|
176
|
+
return data
|
|
177
|
+
except (sqlite3.Error, json.JSONDecodeError) as exc:
|
|
178
|
+
logger.debug("cursor_config: read error: %s", exc)
|
|
179
|
+
return None
|
|
180
|
+
finally:
|
|
181
|
+
conn.close()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def write_cursor_settings(
|
|
185
|
+
db_path: Path,
|
|
186
|
+
settings_key: str,
|
|
187
|
+
data: dict,
|
|
188
|
+
) -> bool:
|
|
189
|
+
"""Write the settings blob back to Cursor's state.vscdb."""
|
|
190
|
+
clean = {k: v for k, v in data.items() if not k.startswith("_mtrx_")}
|
|
191
|
+
conn = _open_db(db_path)
|
|
192
|
+
if conn is None:
|
|
193
|
+
return False
|
|
194
|
+
try:
|
|
195
|
+
cursor = conn.cursor()
|
|
196
|
+
cursor.execute(
|
|
197
|
+
"INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)",
|
|
198
|
+
(settings_key, json.dumps(clean)),
|
|
199
|
+
)
|
|
200
|
+
conn.commit()
|
|
201
|
+
return True
|
|
202
|
+
except sqlite3.Error as exc:
|
|
203
|
+
logger.debug("cursor_config: write error: %s", exc)
|
|
204
|
+
return False
|
|
205
|
+
finally:
|
|
206
|
+
conn.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def configure_cursor_for_proxy(
|
|
210
|
+
proxy_url: str,
|
|
211
|
+
proxy_api_key: str = "mtrx-cursor-proxy",
|
|
212
|
+
*,
|
|
213
|
+
db_path: Path | None = None,
|
|
214
|
+
) -> dict | None:
|
|
215
|
+
"""
|
|
216
|
+
Configure Cursor to route through the local MTRX proxy.
|
|
217
|
+
|
|
218
|
+
Sets the OpenAI API key and Override Base URL in state.vscdb.
|
|
219
|
+
Returns the previous settings dict (for later restoration), or None on failure.
|
|
220
|
+
"""
|
|
221
|
+
db_path = db_path or cursor_state_db_path()
|
|
222
|
+
current = read_cursor_settings(db_path)
|
|
223
|
+
|
|
224
|
+
if current is None:
|
|
225
|
+
# Settings key not found — may need user to set a dummy key via UI first.
|
|
226
|
+
# Try to insert a fresh settings blob under the first candidate key.
|
|
227
|
+
conn = _open_db(db_path)
|
|
228
|
+
if conn is None:
|
|
229
|
+
return None
|
|
230
|
+
try:
|
|
231
|
+
settings_key = _CANDIDATE_SETTINGS_KEYS[0]
|
|
232
|
+
fresh: dict = {
|
|
233
|
+
_OPENAI_KEY_FIELD: proxy_api_key,
|
|
234
|
+
_OPENAI_BASE_URL_FIELD: proxy_url,
|
|
235
|
+
_OVERRIDE_OPENAI_BASE_URL_FIELD: True,
|
|
236
|
+
}
|
|
237
|
+
cursor = conn.cursor()
|
|
238
|
+
cursor.execute(
|
|
239
|
+
"INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)",
|
|
240
|
+
(settings_key, json.dumps(fresh)),
|
|
241
|
+
)
|
|
242
|
+
conn.commit()
|
|
243
|
+
return {"_mtrx_settings_key": settings_key, "_mtrx_was_absent": True}
|
|
244
|
+
except sqlite3.Error as exc:
|
|
245
|
+
logger.debug("cursor_config: fresh insert error: %s", exc)
|
|
246
|
+
return None
|
|
247
|
+
finally:
|
|
248
|
+
conn.close()
|
|
249
|
+
|
|
250
|
+
settings_key = current.pop("_mtrx_settings_key", _CANDIDATE_SETTINGS_KEYS[0])
|
|
251
|
+
previous = {
|
|
252
|
+
_OPENAI_KEY_FIELD: current.get(_OPENAI_KEY_FIELD),
|
|
253
|
+
_OPENAI_BASE_URL_FIELD: current.get(_OPENAI_BASE_URL_FIELD),
|
|
254
|
+
_OVERRIDE_OPENAI_BASE_URL_FIELD: current.get(_OVERRIDE_OPENAI_BASE_URL_FIELD),
|
|
255
|
+
_ANTHROPIC_KEY_FIELD: current.get(_ANTHROPIC_KEY_FIELD),
|
|
256
|
+
"_mtrx_settings_key": settings_key,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
current[_OPENAI_KEY_FIELD] = proxy_api_key
|
|
260
|
+
current[_OPENAI_BASE_URL_FIELD] = proxy_url
|
|
261
|
+
current[_OVERRIDE_OPENAI_BASE_URL_FIELD] = True
|
|
262
|
+
|
|
263
|
+
if write_cursor_settings(db_path, settings_key, current):
|
|
264
|
+
return previous
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def restore_cursor_settings(
|
|
269
|
+
previous: dict,
|
|
270
|
+
*,
|
|
271
|
+
db_path: Path | None = None,
|
|
272
|
+
) -> bool:
|
|
273
|
+
"""Restore Cursor settings to their pre-proxy state."""
|
|
274
|
+
db_path = db_path or cursor_state_db_path()
|
|
275
|
+
settings_key = previous.get("_mtrx_settings_key", "")
|
|
276
|
+
|
|
277
|
+
if previous.get("_mtrx_was_absent"):
|
|
278
|
+
conn = _open_db(db_path)
|
|
279
|
+
if conn is None:
|
|
280
|
+
return False
|
|
281
|
+
try:
|
|
282
|
+
cursor = conn.cursor()
|
|
283
|
+
cursor.execute(
|
|
284
|
+
"DELETE FROM ItemTable WHERE key = ?", (settings_key,)
|
|
285
|
+
)
|
|
286
|
+
conn.commit()
|
|
287
|
+
return True
|
|
288
|
+
except sqlite3.Error:
|
|
289
|
+
return False
|
|
290
|
+
finally:
|
|
291
|
+
conn.close()
|
|
292
|
+
|
|
293
|
+
if not settings_key:
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
current = read_cursor_settings(db_path)
|
|
297
|
+
if current is None:
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
current.pop("_mtrx_settings_key", None)
|
|
301
|
+
|
|
302
|
+
for field in (
|
|
303
|
+
_OPENAI_KEY_FIELD,
|
|
304
|
+
_OPENAI_BASE_URL_FIELD,
|
|
305
|
+
_OVERRIDE_OPENAI_BASE_URL_FIELD,
|
|
306
|
+
_ANTHROPIC_KEY_FIELD,
|
|
307
|
+
):
|
|
308
|
+
old_value = previous.get(field)
|
|
309
|
+
if old_value is None:
|
|
310
|
+
current.pop(field, None)
|
|
311
|
+
else:
|
|
312
|
+
current[field] = old_value
|
|
313
|
+
|
|
314
|
+
return write_cursor_settings(db_path, settings_key, current)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def print_manual_setup_instructions(proxy_url: str) -> None:
|
|
318
|
+
"""Print step-by-step instructions for the user to configure Cursor manually."""
|
|
319
|
+
print()
|
|
320
|
+
print(" Could not auto-configure Cursor settings.")
|
|
321
|
+
print(" Please configure manually in Cursor:")
|
|
322
|
+
print()
|
|
323
|
+
print(" 1. Open Cursor Settings (Cmd+, or Ctrl+,)")
|
|
324
|
+
print(" 2. Go to Models")
|
|
325
|
+
print(" 3. In the OpenAI API Keys section:")
|
|
326
|
+
print(f" - API Key: mtrx-cursor-proxy")
|
|
327
|
+
print(f" - Override Base URL: {proxy_url}")
|
|
328
|
+
print(" - Toggle ON 'Override OpenAI Base URL'")
|
|
329
|
+
print()
|
|
330
|
+
print(" All models (Claude, GPT, Gemini, etc.) will route through MTRX.")
|
|
331
|
+
print()
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local HTTP proxy server for routing Cursor IDE requests through MTRX.
|
|
3
|
+
|
|
4
|
+
Sits between Cursor and the MTRX API, injecting X-Matrx-* headers on every
|
|
5
|
+
request so that Cursor gets the same full-featured proxy pipeline as
|
|
6
|
+
``mtrx claude`` and ``mtrx codex`` (injection, compression, memory, groups,
|
|
7
|
+
observability).
|
|
8
|
+
|
|
9
|
+
Cursor's "Override OpenAI Base URL" sends ALL model requests (OpenAI,
|
|
10
|
+
Anthropic, Google, etc.) in OpenAI Chat Completions format to the configured
|
|
11
|
+
URL. The MTRX router auto-detects the provider from the model name.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import base64
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import platform
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import signal
|
|
24
|
+
import socket
|
|
25
|
+
import threading
|
|
26
|
+
import uuid
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_PROXY_API_KEY = "mtrx-cursor-proxy"
|
|
34
|
+
_FORWARDED_METHODS = {"POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _capture_env_snapshot() -> dict:
|
|
38
|
+
out: dict = {
|
|
39
|
+
"os": platform.system(),
|
|
40
|
+
"shell": os.environ.get("SHELL", os.environ.get("COMSPEC", "")),
|
|
41
|
+
"cwd": os.getcwd(),
|
|
42
|
+
}
|
|
43
|
+
out["venv"] = os.environ.get("VIRTUAL_ENV", "") or None
|
|
44
|
+
node = shutil.which("node")
|
|
45
|
+
if node:
|
|
46
|
+
import subprocess as _sp
|
|
47
|
+
try:
|
|
48
|
+
r = _sp.run([node, "-v"], capture_output=True, text=True, timeout=2)
|
|
49
|
+
out["node"] = r.stdout.strip() if r.returncode == 0 else None
|
|
50
|
+
except Exception:
|
|
51
|
+
out["node"] = None
|
|
52
|
+
else:
|
|
53
|
+
out["node"] = None
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CursorProxyServer:
|
|
58
|
+
"""Async HTTP proxy that injects MTRX headers and forwards to the MTRX API."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
matrx_key: str,
|
|
64
|
+
matrx_base_url: str,
|
|
65
|
+
session_id: str | None = None,
|
|
66
|
+
group_id: str = "",
|
|
67
|
+
project_id: str = "",
|
|
68
|
+
host: str = "127.0.0.1",
|
|
69
|
+
port: int = 0,
|
|
70
|
+
):
|
|
71
|
+
self.matrx_key = matrx_key
|
|
72
|
+
self.matrx_base_url = matrx_base_url.rstrip("/")
|
|
73
|
+
self.session_id = session_id or str(uuid.uuid4())
|
|
74
|
+
self.group_id = group_id
|
|
75
|
+
self.project_id = project_id
|
|
76
|
+
self.host = host
|
|
77
|
+
self.port = port
|
|
78
|
+
|
|
79
|
+
env_snap = _capture_env_snapshot()
|
|
80
|
+
self._env_b64 = (
|
|
81
|
+
base64.b64encode(json.dumps(env_snap).encode()).decode()
|
|
82
|
+
if env_snap
|
|
83
|
+
else ""
|
|
84
|
+
)
|
|
85
|
+
self._server: asyncio.Server | None = None
|
|
86
|
+
self._http_client: httpx.AsyncClient | None = None
|
|
87
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def url(self) -> str:
|
|
91
|
+
return f"http://{self.host}:{self.port}/v1"
|
|
92
|
+
|
|
93
|
+
def _build_matrx_headers(self) -> dict[str, str]:
|
|
94
|
+
headers: dict[str, str] = {
|
|
95
|
+
"X-Matrx-Key": self.matrx_key,
|
|
96
|
+
"X-Matrx-Agent-Id": "cursor",
|
|
97
|
+
"X-Matrx-Provider": "cursor",
|
|
98
|
+
"X-Matrx-Session-Id": self.session_id,
|
|
99
|
+
}
|
|
100
|
+
if self.group_id:
|
|
101
|
+
headers["X-Matrx-Group"] = self.group_id
|
|
102
|
+
if self.project_id:
|
|
103
|
+
headers["X-Matrx-Project-Id"] = self.project_id
|
|
104
|
+
if self._env_b64:
|
|
105
|
+
headers["X-Matrx-Env"] = self._env_b64
|
|
106
|
+
return headers
|
|
107
|
+
|
|
108
|
+
async def _handle_request(
|
|
109
|
+
self,
|
|
110
|
+
reader: asyncio.StreamReader,
|
|
111
|
+
writer: asyncio.StreamWriter,
|
|
112
|
+
) -> None:
|
|
113
|
+
try:
|
|
114
|
+
await self._process_http(reader, writer)
|
|
115
|
+
except Exception:
|
|
116
|
+
logger.debug("cursor_proxy: connection error", exc_info=True)
|
|
117
|
+
finally:
|
|
118
|
+
try:
|
|
119
|
+
writer.close()
|
|
120
|
+
await writer.wait_closed()
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
async def _process_http(
|
|
125
|
+
self,
|
|
126
|
+
reader: asyncio.StreamReader,
|
|
127
|
+
writer: asyncio.StreamWriter,
|
|
128
|
+
) -> None:
|
|
129
|
+
request_line = await reader.readline()
|
|
130
|
+
if not request_line:
|
|
131
|
+
return
|
|
132
|
+
parts = request_line.decode("utf-8", errors="replace").strip().split(" ", 2)
|
|
133
|
+
if len(parts) < 3:
|
|
134
|
+
return
|
|
135
|
+
method, raw_path, _ = parts
|
|
136
|
+
|
|
137
|
+
headers_raw: dict[str, str] = {}
|
|
138
|
+
while True:
|
|
139
|
+
line = await reader.readline()
|
|
140
|
+
decoded = line.decode("utf-8", errors="replace").strip()
|
|
141
|
+
if not decoded:
|
|
142
|
+
break
|
|
143
|
+
if ":" in decoded:
|
|
144
|
+
key, _, value = decoded.partition(":")
|
|
145
|
+
headers_raw[key.strip().lower()] = value.strip()
|
|
146
|
+
|
|
147
|
+
content_length = int(headers_raw.get("content-length", "0"))
|
|
148
|
+
body = b""
|
|
149
|
+
if content_length > 0:
|
|
150
|
+
body = await reader.readexactly(content_length)
|
|
151
|
+
|
|
152
|
+
if method not in _FORWARDED_METHODS:
|
|
153
|
+
self._send_response(writer, 405, {"error": "Method not allowed"})
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
upstream_path = raw_path
|
|
157
|
+
if upstream_path.startswith("/v1/"):
|
|
158
|
+
upstream_path = "/" + upstream_path[4:]
|
|
159
|
+
elif upstream_path == "/v1":
|
|
160
|
+
upstream_path = "/"
|
|
161
|
+
|
|
162
|
+
upstream_url = f"{self.matrx_base_url}/v1{upstream_path}"
|
|
163
|
+
|
|
164
|
+
upstream_headers: dict[str, str] = {}
|
|
165
|
+
for key, value in headers_raw.items():
|
|
166
|
+
if key in {"host", "connection", "transfer-encoding"}:
|
|
167
|
+
continue
|
|
168
|
+
if key == "authorization":
|
|
169
|
+
continue
|
|
170
|
+
upstream_headers[key] = value
|
|
171
|
+
|
|
172
|
+
upstream_headers.update(self._build_matrx_headers())
|
|
173
|
+
upstream_headers["Authorization"] = f"Bearer {self.matrx_key}"
|
|
174
|
+
|
|
175
|
+
is_stream = False
|
|
176
|
+
if body and method == "POST":
|
|
177
|
+
try:
|
|
178
|
+
parsed = json.loads(body)
|
|
179
|
+
is_stream = parsed.get("stream", False)
|
|
180
|
+
except (json.JSONDecodeError, AttributeError):
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
assert self._http_client is not None
|
|
184
|
+
|
|
185
|
+
if is_stream:
|
|
186
|
+
await self._proxy_streaming(
|
|
187
|
+
writer, method, upstream_url, upstream_headers, body
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
await self._proxy_buffered(
|
|
191
|
+
writer, method, upstream_url, upstream_headers, body
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
async def _proxy_buffered(
|
|
195
|
+
self,
|
|
196
|
+
writer: asyncio.StreamWriter,
|
|
197
|
+
method: str,
|
|
198
|
+
url: str,
|
|
199
|
+
headers: dict[str, str],
|
|
200
|
+
body: bytes,
|
|
201
|
+
) -> None:
|
|
202
|
+
assert self._http_client is not None
|
|
203
|
+
try:
|
|
204
|
+
resp = await self._http_client.request(
|
|
205
|
+
method, url, headers=headers, content=body, timeout=120
|
|
206
|
+
)
|
|
207
|
+
resp_headers = {
|
|
208
|
+
"Content-Type": resp.headers.get("content-type", "application/json"),
|
|
209
|
+
"Content-Length": str(len(resp.content)),
|
|
210
|
+
}
|
|
211
|
+
for h in ("x-matrx-request-id", "x-matrx-latency-ms", "x-matrx-tokens-saved"):
|
|
212
|
+
if h in resp.headers:
|
|
213
|
+
resp_headers[h] = resp.headers[h]
|
|
214
|
+
self._send_raw_response(writer, resp.status_code, resp_headers, resp.content)
|
|
215
|
+
except httpx.HTTPError as exc:
|
|
216
|
+
logger.warning("cursor_proxy: upstream error: %s", exc)
|
|
217
|
+
self._send_response(writer, 502, {"error": f"Upstream error: {exc}"})
|
|
218
|
+
|
|
219
|
+
async def _proxy_streaming(
|
|
220
|
+
self,
|
|
221
|
+
writer: asyncio.StreamWriter,
|
|
222
|
+
method: str,
|
|
223
|
+
url: str,
|
|
224
|
+
headers: dict[str, str],
|
|
225
|
+
body: bytes,
|
|
226
|
+
) -> None:
|
|
227
|
+
assert self._http_client is not None
|
|
228
|
+
try:
|
|
229
|
+
async with self._http_client.stream(
|
|
230
|
+
method, url, headers=headers, content=body, timeout=300
|
|
231
|
+
) as resp:
|
|
232
|
+
resp_headers = {
|
|
233
|
+
"Content-Type": resp.headers.get(
|
|
234
|
+
"content-type", "text/event-stream"
|
|
235
|
+
),
|
|
236
|
+
"Cache-Control": "no-cache",
|
|
237
|
+
"Transfer-Encoding": "chunked",
|
|
238
|
+
}
|
|
239
|
+
for h in ("x-matrx-request-id", "x-matrx-latency-ms", "x-matrx-tokens-saved"):
|
|
240
|
+
if h in resp.headers:
|
|
241
|
+
resp_headers[h] = resp.headers[h]
|
|
242
|
+
|
|
243
|
+
header_block = f"HTTP/1.1 {resp.status_code} OK\r\n"
|
|
244
|
+
for k, v in resp_headers.items():
|
|
245
|
+
header_block += f"{k}: {v}\r\n"
|
|
246
|
+
header_block += "\r\n"
|
|
247
|
+
writer.write(header_block.encode("utf-8"))
|
|
248
|
+
await writer.drain()
|
|
249
|
+
|
|
250
|
+
async for chunk in resp.aiter_bytes():
|
|
251
|
+
chunk_header = f"{len(chunk):x}\r\n".encode("utf-8")
|
|
252
|
+
writer.write(chunk_header + chunk + b"\r\n")
|
|
253
|
+
await writer.drain()
|
|
254
|
+
|
|
255
|
+
writer.write(b"0\r\n\r\n")
|
|
256
|
+
await writer.drain()
|
|
257
|
+
except httpx.HTTPError as exc:
|
|
258
|
+
logger.warning("cursor_proxy: streaming error: %s", exc)
|
|
259
|
+
self._send_response(writer, 502, {"error": f"Upstream error: {exc}"})
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def _send_response(
|
|
263
|
+
writer: asyncio.StreamWriter,
|
|
264
|
+
status: int,
|
|
265
|
+
body: dict[str, Any],
|
|
266
|
+
) -> None:
|
|
267
|
+
content = json.dumps(body).encode("utf-8")
|
|
268
|
+
CursorProxyServer._send_raw_response(
|
|
269
|
+
writer,
|
|
270
|
+
status,
|
|
271
|
+
{"Content-Type": "application/json", "Content-Length": str(len(content))},
|
|
272
|
+
content,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _send_raw_response(
|
|
277
|
+
writer: asyncio.StreamWriter,
|
|
278
|
+
status: int,
|
|
279
|
+
headers: dict[str, str],
|
|
280
|
+
content: bytes,
|
|
281
|
+
) -> None:
|
|
282
|
+
reason = "OK" if 200 <= status < 300 else "Error"
|
|
283
|
+
header_block = f"HTTP/1.1 {status} {reason}\r\n"
|
|
284
|
+
for k, v in headers.items():
|
|
285
|
+
header_block += f"{k}: {v}\r\n"
|
|
286
|
+
header_block += "\r\n"
|
|
287
|
+
writer.write(header_block.encode("utf-8") + content)
|
|
288
|
+
|
|
289
|
+
def _pick_port(self) -> int:
|
|
290
|
+
if self.port:
|
|
291
|
+
return self.port
|
|
292
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
293
|
+
s.bind(("127.0.0.1", 0))
|
|
294
|
+
return s.getsockname()[1]
|
|
295
|
+
|
|
296
|
+
async def _run(self) -> None:
|
|
297
|
+
self.port = self._pick_port()
|
|
298
|
+
self._http_client = httpx.AsyncClient(http2=False, follow_redirects=True)
|
|
299
|
+
self._server = await asyncio.start_server(
|
|
300
|
+
self._handle_request, self.host, self.port
|
|
301
|
+
)
|
|
302
|
+
logger.info("cursor_proxy: listening on %s:%s", self.host, self.port)
|
|
303
|
+
async with self._server:
|
|
304
|
+
await self._server.serve_forever()
|
|
305
|
+
|
|
306
|
+
async def _shutdown(self) -> None:
|
|
307
|
+
if self._server:
|
|
308
|
+
self._server.close()
|
|
309
|
+
await self._server.wait_closed()
|
|
310
|
+
if self._http_client:
|
|
311
|
+
await self._http_client.aclose()
|
|
312
|
+
|
|
313
|
+
def start_background(self) -> None:
|
|
314
|
+
"""Start the proxy in a background daemon thread. Returns once listening."""
|
|
315
|
+
ready = threading.Event()
|
|
316
|
+
|
|
317
|
+
def _run_loop() -> None:
|
|
318
|
+
loop = asyncio.new_event_loop()
|
|
319
|
+
asyncio.set_event_loop(loop)
|
|
320
|
+
self._loop = loop
|
|
321
|
+
|
|
322
|
+
async def _start_and_signal() -> None:
|
|
323
|
+
self.port = self._pick_port()
|
|
324
|
+
self._http_client = httpx.AsyncClient(
|
|
325
|
+
http2=False, follow_redirects=True
|
|
326
|
+
)
|
|
327
|
+
self._server = await asyncio.start_server(
|
|
328
|
+
self._handle_request, self.host, self.port
|
|
329
|
+
)
|
|
330
|
+
ready.set()
|
|
331
|
+
async with self._server:
|
|
332
|
+
await self._server.serve_forever()
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
loop.run_until_complete(_start_and_signal())
|
|
336
|
+
except asyncio.CancelledError:
|
|
337
|
+
pass
|
|
338
|
+
finally:
|
|
339
|
+
loop.run_until_complete(self._shutdown())
|
|
340
|
+
loop.close()
|
|
341
|
+
|
|
342
|
+
thread = threading.Thread(target=_run_loop, daemon=True)
|
|
343
|
+
thread.start()
|
|
344
|
+
ready.wait(timeout=10)
|
|
345
|
+
if not ready.is_set():
|
|
346
|
+
raise RuntimeError("Cursor proxy failed to start within 10 seconds")
|
|
347
|
+
|
|
348
|
+
def stop(self) -> None:
|
|
349
|
+
"""Stop the background proxy."""
|
|
350
|
+
if self._server and self._loop and self._loop.is_running():
|
|
351
|
+
self._loop.call_soon_threadsafe(self._server.close)
|