@agentsdock/server 1.0.7-beta.5 → 1.0.8-beta.2

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 (45) hide show
  1. package/README.md +20 -6
  2. package/package.json +1 -1
  3. package/server/VERSION +1 -1
  4. package/server/agent_server.py +5283 -698
  5. package/server/agentsdock_chats.py +2 -1
  6. package/server/chat_mailbox.py +23 -6
  7. package/server/claude_history_repair.py +45 -4
  8. package/server/claude_model_catalog.py +200 -0
  9. package/server/claude_sdk_client.py +63 -24
  10. package/server/claude_side_question.py +1 -8
  11. package/server/codex_app_server.py +90 -30
  12. package/server/codex_auth.py +119 -0
  13. package/server/codex_history_repair.py +79 -14
  14. package/server/codex_provider.py +160 -77
  15. package/server/codex_side_question.py +154 -57
  16. package/server/cursor_agent_client.py +27 -1
  17. package/server/cursor_api_key.py +64 -0
  18. package/server/cursor_history.py +219 -0
  19. package/server/cursor_provider_mcp.py +290 -0
  20. package/server/execution_durability.py +10 -6
  21. package/server/execution_preparation.py +6 -2
  22. package/server/install.sh +282 -78
  23. package/server/instances.sh +15 -0
  24. package/server/interactive_chat_native.py +4 -3
  25. package/server/interactive_chat_projection.py +6 -19
  26. package/server/interactive_chat_share_routes.py +71 -45
  27. package/server/interactive_chat_share_web.py +2 -2
  28. package/server/interactive_chat_shares.py +6 -11
  29. package/server/local_session_ownership.py +232 -0
  30. package/server/opencode_agent_client.py +685 -0
  31. package/server/provider_commands.py +579 -0
  32. package/server/provider_connections.py +665 -0
  33. package/server/provider_usage.py +241 -0
  34. package/server/public_chat_shares.py +60 -62
  35. package/server/public_chat_transcript.py +9 -31
  36. package/server/pyproject.toml +1 -0
  37. package/server/server_instances.py +931 -0
  38. package/server/shared_chat_video_stream.py +21 -2
  39. package/server/shared_chat_videos.py +54 -24
  40. package/server/side_questions.py +493 -10
  41. package/server/title_generation.py +4 -1
  42. package/server/uninstall.sh +95 -17
  43. package/server/update_preparation.py +4 -1
  44. package/server/update_runner.py +16 -0
  45. package/server/uv.lock +3 -1
@@ -0,0 +1,665 @@
1
+ """Private endpoint settings and immutable, explicitly selected chat bindings."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from datetime import datetime, timezone
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from urllib.parse import urlsplit
10
+ import stat
11
+ import tempfile
12
+ import threading
13
+ import hashlib
14
+ import hmac
15
+ import re
16
+ import time
17
+ import uuid
18
+ import subprocess
19
+ import sys
20
+
21
+ import httpx
22
+ from fastapi import APIRouter, HTTPException, Request
23
+ from fastapi.responses import JSONResponse
24
+
25
+ from codex_provider import validate_selection, validate_model, endpoint_model_catalog
26
+
27
+ MAX_BODY_BYTES = 16 * 1024
28
+ PROTOCOLS = {"claude": {"anthropic"}, "opencode": {"anthropic", "chat_completions", "responses"}, "cursor": {"cursor"}}
29
+ RESULTS = {"verified", "authentication_failed", "rate_limited", "unsupported", "connection_failed", "invalid_response", "model_required", "cli_update_required"}
30
+
31
+
32
+ def backend_name(value: str) -> str:
33
+ if value not in PROTOCOLS:
34
+ raise HTTPException(400, "This runtime does not support these endpoint settings.")
35
+ return value
36
+
37
+
38
+ def revision(value) -> int:
39
+ if type(value) is not int or not 0 <= value < 2**53 - 1:
40
+ raise HTTPException(400, "Refresh the saved endpoint before changing it.")
41
+ return value
42
+
43
+
44
+ def selection(backend: str, value: object) -> dict:
45
+ backend_name(backend)
46
+ if not isinstance(value, dict) or set(value) != {"base_url", "api_key", "model", "protocol", "auth_header", "expected_revision"}:
47
+ raise HTTPException(400, "Provide an endpoint, key, model, protocol and revision.")
48
+ if backend == "cursor":
49
+ from cursor_api_key import ENDPOINT
50
+ if value["base_url"] != ENDPOINT or value["protocol"] != "cursor" or value["auth_header"] != "bearer":
51
+ raise HTTPException(400, "Cursor API keys use Cursor's official service only.")
52
+ if not isinstance(value["protocol"], str) or not isinstance(value["auth_header"], str) or value["protocol"] not in PROTOCOLS[backend] or value["auth_header"] not in {"bearer", "x-api-key"}:
53
+ raise HTTPException(400, "Unsupported API protocol or authentication header.")
54
+ if value["protocol"] != "anthropic" and value["auth_header"] != "bearer":
55
+ raise HTTPException(400, "This API protocol requires bearer authentication.")
56
+ selected = validate_selection({"base_url": value["base_url"], "api_key": value["api_key"],
57
+ "model": None if value["model"] == "" else value["model"]})
58
+ selected.setdefault("model", None)
59
+ if backend == "cursor" and selected["model"] is None:
60
+ selected["model"] = "auto"
61
+ if selected["base_url"].lower().endswith("/messages"):
62
+ raise HTTPException(400, "Enter the base URL, without an API operation suffix.")
63
+ return {**selected, "protocol": value["protocol"], "auth_header": value["auth_header"],
64
+ "expected_revision": revision(value["expected_revision"])}
65
+
66
+
67
+ class ConnectionStore:
68
+ """Private, atomic, revision-fenced records; API responses never return keys."""
69
+ def __init__(self, root: Path):
70
+ self.root = root
71
+ self.lock = threading.RLock()
72
+ self.catalog_cache = {}
73
+
74
+ def _directory(self, create=False):
75
+ if self.root.is_symlink() or self.root.parent.is_symlink():
76
+ raise ValueError("unsafe directory")
77
+ if create:
78
+ self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
79
+ if self.root.exists():
80
+ info = self.root.stat()
81
+ if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
82
+ raise ValueError("non-private directory")
83
+
84
+ def read(self, backend: str, credential_id: str | None = None) -> dict:
85
+ backend_name(backend)
86
+ if credential_id is not None and not re.fullmatch(r"[a-f0-9]{32}", credential_id):
87
+ raise HTTPException(409, "Invalid chat endpoint binding.")
88
+ with self.lock:
89
+ try:
90
+ self._directory()
91
+ try:
92
+ name = f"{backend}-{credential_id}" if credential_id else backend
93
+ fd = os.open(self.root / f"{name}.json", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
94
+ except FileNotFoundError:
95
+ return {"revision": 0, "configured": False}
96
+ with os.fdopen(fd, "rb") as stream:
97
+ info = os.fstat(stream.fileno())
98
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1:
99
+ raise ValueError("unsafe file")
100
+ raw = stream.read(MAX_BODY_BYTES + 1)
101
+ if len(raw) > MAX_BODY_BYTES:
102
+ raise ValueError("oversized record")
103
+ value = json.loads(raw)
104
+ revision(value["revision"])
105
+ if "revoked_through" in value:
106
+ revision(value["revoked_through"])
107
+ if type(value.get("configured")) is not bool:
108
+ raise ValueError("invalid record")
109
+ if value["configured"]:
110
+ selection(backend, {key: value[key] for key in ("base_url", "api_key", "model", "protocol", "auth_header", "expected_revision")})
111
+ if value.get("last_result") not in RESULTS or not isinstance(value.get("checked_at"), str):
112
+ raise ValueError("invalid evidence")
113
+ return value
114
+ except Exception:
115
+ raise HTTPException(503, "Saved endpoint settings are unavailable.") from None
116
+
117
+ def public(self, backend: str) -> dict:
118
+ value = self.read(backend)
119
+ return {"backend": backend, "scope": "per_chat", "revision": value["revision"],
120
+ "configured": value["configured"], "has_api_key": value["configured"],
121
+ **{key: value.get(key) if value["configured"] else None for key in
122
+ ("base_url", "model", "protocol", "auth_header", "checked_at", "last_result")}}
123
+
124
+ def write(self, backend: str, expected: int, selected: dict | None, result: str | None = None, *, checked_at: str | None = None) -> dict:
125
+ with self.lock:
126
+ previous = self.read(backend)
127
+ if previous["revision"] != revision(expected):
128
+ raise HTTPException(409, "Endpoint settings changed. Refresh and try again.")
129
+ # An atomic tombstone revokes every prior binding, including copies
130
+ # retained by older servers. Saving another key must not revive them.
131
+ revoked = self._revoked_through(previous)
132
+ value = {"revision": expected + 1, "configured": selected is not None,
133
+ "revoked_through": max(revoked, expected) if selected is None else revoked}
134
+ if selected is not None:
135
+ if result not in RESULTS:
136
+ raise ValueError("invalid check result")
137
+ value.update(selection(backend, selected))
138
+ value.update(last_result=result, checked_at=checked_at or datetime.now(timezone.utc).isoformat())
139
+ try:
140
+ self._directory(create=True)
141
+ target = self.root / f"{backend}.json"
142
+ if target.is_symlink():
143
+ raise ValueError("unsafe target")
144
+ fd, temporary = tempfile.mkstemp(prefix=".write-", dir=self.root)
145
+ try:
146
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
147
+ json.dump(value, stream)
148
+ stream.flush()
149
+ os.fsync(stream.fileno())
150
+ os.replace(temporary, target)
151
+ finally:
152
+ if os.path.exists(temporary):
153
+ os.unlink(temporary)
154
+ except Exception:
155
+ raise HTTPException(503, "Could not save endpoint settings.") from None
156
+ return self.public(backend)
157
+
158
+ @staticmethod
159
+ def _revoked_through(current: dict) -> int:
160
+ # Legacy Forget wrote an empty, revisioned current record.
161
+ return max(current.get("revoked_through", 0),
162
+ current["revision"] if not current["configured"] else 0)
163
+
164
+ def bind(self, session: dict) -> dict:
165
+ """Pin once; replacement preserves identity, Forget revokes it."""
166
+ if session.get("provider_connection") != "custom":
167
+ return {}
168
+ backend = backend_name(session.get("backend"))
169
+ with self.lock:
170
+ credential_id = session.get("provider_connection_revision")
171
+ value = self.read(backend, credential_id)
172
+ if credential_id:
173
+ current = self.read(backend)
174
+ if not current["configured"] or value["revision"] <= self._revoked_through(current):
175
+ # Explicitly reconnecting the exact original connection
176
+ # authorizes reuse, without moving history to another host
177
+ # or account. No writes or remote auth probes on projection.
178
+ restored = (current["configured"] and current.get("last_result") == "verified"
179
+ and all(current.get(k) == value.get(k) for k in ("base_url", "protocol", "auth_header"))
180
+ and hmac.compare_digest(current.get("api_key", "").encode(), value.get("api_key", "").encode()))
181
+ if not restored:
182
+ raise HTTPException(409, "This chat's API connection is disconnected. Reconnect its original endpoint and API key in My Agents, then retry.")
183
+ if not value.get("configured") or value.get("last_result") != "verified":
184
+ raise HTTPException(409, "Connect this custom API in My Agents before creating a chat.")
185
+ if not credential_id:
186
+ credential_id = uuid.uuid4().hex
187
+ self._directory(create=True)
188
+ path = self.root / f"{backend}-{credential_id}.json"
189
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
190
+ with os.fdopen(fd, "w") as stream:
191
+ json.dump(value, stream)
192
+ stream.flush()
193
+ os.fsync(stream.fileno())
194
+ return {**value, "credential_id": credential_id, "model": session.get("model") or value.get("model")}
195
+
196
+ def for_session(self, session: dict) -> dict:
197
+ if session.get("provider_connection") != "custom":
198
+ return {}
199
+ if not session.get("provider_connection_revision"):
200
+ raise HTTPException(409, "This chat has no saved custom API binding; create a new chat.")
201
+ return self.bind(session)
202
+
203
+ def catalog(self, backend: str, *, installed: bool = True, session: dict | None = None, summary: bool = False) -> dict:
204
+ try:
205
+ value = self.for_session(session) if session else self.read(backend)
206
+ configured = value.get("configured") is True and value.get("last_result") == "verified"
207
+ if summary:
208
+ # Large endpoint inventories belong to detail/settings reads,
209
+ # not repeated once for every row of a session-list response.
210
+ return {"configured": configured, "available": configured and installed,
211
+ "model": value.get("model"), "base_url": value.get("base_url")}
212
+ models = []
213
+ if configured:
214
+ cache_key = (backend, value["revision"])
215
+ cached = self.catalog_cache.get(cache_key)
216
+ if cached and time.monotonic() - cached[0] < 300:
217
+ models = cached[1]
218
+ elif session is None and backend != "cursor": # Cursor discovery uses an async CLI probe.
219
+ models = discover_models(value)
220
+ self.catalog_cache[cache_key] = (time.monotonic(), models)
221
+ if len(self.catalog_cache) > 32:
222
+ self.catalog_cache.pop(next(iter(self.catalog_cache)))
223
+ if backend == "cursor" and not models:
224
+ models = [{"value": "auto", "label": "Auto"}]
225
+ model = value.get("model")
226
+ if model and not any(item["value"] == model for item in models):
227
+ models = [{"value": model, "label": model}, *models]
228
+ return {"configured": configured, "available": configured and installed,
229
+ "models": models, "efforts": [], "default_model": value.get("model"),
230
+ "model": value.get("model"), "default_effort": None, "base_url": value.get("base_url")}
231
+ except HTTPException:
232
+ return {"configured": False, "available": False, "models": [], "efforts": [], "model": None, "base_url": None}
233
+
234
+ def claude_overrides(self, session: dict) -> tuple[dict, str | None]:
235
+ value = self.for_session(session)
236
+ if not value:
237
+ return {}, None
238
+ model = require_model(value)
239
+ env = {"ANTHROPIC_BASE_URL": value["base_url"].removesuffix("/v1"),
240
+ "ANTHROPIC_API_KEY": value["api_key"] if value["auth_header"] == "x-api-key" else "",
241
+ "ANTHROPIC_AUTH_TOKEN": value["api_key"] if value["auth_header"] == "bearer" else "",
242
+ "CLAUDE_CODE_OAUTH_TOKEN": "", "CLAUDE_CODE_USE_BEDROCK": "0",
243
+ "CLAUDE_CODE_USE_VERTEX": "0", "CLAUDE_CODE_USE_FOUNDRY": "0",
244
+ "ANTHROPIC_MODEL": model, "ANTHROPIC_SMALL_FAST_MODEL": model,
245
+ "ANTHROPIC_DEFAULT_OPUS_MODEL": model, "ANTHROPIC_DEFAULT_SONNET_MODEL": model,
246
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL": model}
247
+ if type(session.get("subagent_limit")) is int:
248
+ env["CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS"] = str(session["subagent_limit"])
249
+ # Flag settings outrank project/user settings. Pass a private file path,
250
+ # never a key-bearing JSON argument visible in process listings.
251
+ content = json.dumps({"env": env, "apiKeyHelper": ""}, sort_keys=True)
252
+ digest = hashlib.sha256(content.encode()).hexdigest()
253
+ path = self.root / f"claude-runtime-{digest}.json"
254
+ with self.lock:
255
+ self._directory(create=True)
256
+ try:
257
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
258
+ except FileExistsError:
259
+ info = path.lstat()
260
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1:
261
+ raise HTTPException(503, "Custom API runtime settings are unavailable.") from None
262
+ else:
263
+ with os.fdopen(fd, "w") as stream:
264
+ stream.write(content)
265
+ return env, str(path)
266
+
267
+ def cursor_overrides(self, session: dict) -> dict:
268
+ if session.get("backend") != "cursor" or session.get("provider_connection") != "custom":
269
+ return {}
270
+ from cursor_api_key import overrides
271
+ return overrides(self.for_session(session)["api_key"])
272
+
273
+ def opencode_overrides(self, session: dict, env: dict) -> dict:
274
+ value = self.for_session(session)
275
+ if not value:
276
+ return {}
277
+ model = require_model(value)
278
+ config = json.loads(env.get("OPENCODE_CONFIG_CONTENT") or "{}")
279
+ adapter = {"anthropic": "@ai-sdk/anthropic", "chat_completions": "@ai-sdk/openai-compatible", "responses": "@ai-sdk/openai"}[value["protocol"]]
280
+ options = {"baseURL": value["base_url"], "apiKey": "{env:AGENTSDOCK_CUSTOM_API_KEY}"}
281
+ if value["protocol"] == "anthropic" and value["auth_header"] == "bearer":
282
+ options["headers"] = {"Authorization": "Bearer {env:AGENTSDOCK_CUSTOM_API_KEY}", "x-api-key": ""}
283
+ config.setdefault("provider", {})["agentsdock_custom"] = {"npm": adapter, "name": "Custom endpoint", "options": options, "models": {model: {"name": model}}}
284
+ config["model"] = "agentsdock_custom/" + model
285
+ config["small_model"] = config["model"]
286
+ config["enabled_providers"] = ["agentsdock_custom"]
287
+ return {"AGENTSDOCK_CUSTOM_API_KEY": value["api_key"], "OPENCODE_CONFIG_CONTENT": json.dumps(config)}
288
+
289
+ def redact(self, session: dict, value):
290
+ if session.get("provider_connection") != "custom":
291
+ return value
292
+ try:
293
+ # Revocation blocks new work, not safe rendering of an already
294
+ # running request. The private original key remains redaction-only.
295
+ identifier = session.get("provider_connection_revision")
296
+ if not identifier:
297
+ raise KeyError("missing binding")
298
+ key = self.read(backend_name(session.get("backend")), identifier)["api_key"]
299
+ except (HTTPException, KeyError):
300
+ return "Custom API output unavailable." if isinstance(value, str) else {"message": "Custom API output unavailable."}
301
+ def clean(item):
302
+ if isinstance(item, str): return item.replace(key, "<api-key>")
303
+ if isinstance(item, list): return [clean(x) for x in item]
304
+ if isinstance(item, dict): return {k: clean(v) for k, v in item.items()}
305
+ return item
306
+ return clean(value)
307
+
308
+
309
+ def require_model(value: dict) -> str:
310
+ model = value.get("model")
311
+ if not isinstance(model, str) or not re.fullmatch(r"[\x21-\x7e]{1,256}", model):
312
+ raise HTTPException(409, "Choose a model for this custom endpoint before sending a message.")
313
+ return model
314
+
315
+
316
+ def discover_models(value: dict) -> list[dict]:
317
+ return endpoint_model_catalog(value, protocol=value["protocol"])["models"]
318
+
319
+
320
+ def native_credentials_present(backend: str, diagnostic: dict) -> bool:
321
+ """Presence only, not a paid/auth-refresh probe; never return credential data."""
322
+ if diagnostic.get("installed") is not True:
323
+ return False
324
+ # Claude/OpenCode successful turns may have used a chat-local custom API.
325
+ # Their runtime-ready cache is not proof of a separate native login.
326
+ if backend not in {"claude", "opencode"} and diagnostic.get("authenticated") is True:
327
+ return True
328
+ try:
329
+ if backend == "claude":
330
+ if any(os.environ.get(key) for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN")):
331
+ return True
332
+ root = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
333
+ path = root / ".credentials.json"
334
+ if path.is_file() and path.stat().st_size < 65536:
335
+ value = json.loads(path.read_text())
336
+ if value.get("claudeAiOauth", {}).get("accessToken"):
337
+ return True
338
+ if sys.platform == "darwin":
339
+ # Omit -w/-g: test existence without extracting a Keychain secret.
340
+ return subprocess.run(["/usr/bin/security", "find-generic-password", "-s", "Claude Code-credentials"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2).returncode == 0
341
+ if backend == "opencode":
342
+ if any(os.environ.get(key) for key in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENCODE_API_KEY")):
343
+ return True
344
+ path = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") / "opencode/auth.json"
345
+ if path.is_file() and path.stat().st_size < 65536:
346
+ value = json.loads(path.read_text())
347
+ return isinstance(value, dict) and any(isinstance(item, dict) and (item.get("key") or item.get("access")) for item in value.values())
348
+ except Exception:
349
+ pass
350
+ return False
351
+
352
+
353
+ async def probe_credentials(selected: dict) -> str:
354
+ """Read-only key check. Public model lists alone never verify a credential."""
355
+ base = selected["base_url"]
356
+ protocol = selected.get("protocol", "responses")
357
+ headers = {"Accept": "application/json", "anthropic-version": "2023-06-01"}
358
+ if selected.get("auth_header") == "x-api-key":
359
+ headers["x-api-key"] = selected["api_key"]
360
+ else:
361
+ headers["Authorization"] = "Bearer " + selected["api_key"]
362
+ url = urlsplit(base)
363
+ router = url.scheme == "https" and url.netloc == "openrouter.ai" and url.path in {"/api", "/api/v1"}
364
+ target = "https://openrouter.ai/api/v1/key" if router else base + (
365
+ "/v1/models" if protocol == "anthropic" and not base.endswith("/v1") else "/models")
366
+ async def send():
367
+ async with httpx.AsyncClient(timeout=10, follow_redirects=False, trust_env=False) as client:
368
+ async with client.stream("GET", target, headers=headers) as response:
369
+ if response.status_code in {401, 403}: return "authentication_failed"
370
+ if response.status_code == 429: return "rate_limited"
371
+ if response.status_code in {404, 405}: return "model_required"
372
+ if response.status_code != 200: return "connection_failed"
373
+ raw = bytearray()
374
+ async for chunk in response.aiter_bytes():
375
+ raw.extend(chunk)
376
+ if len(raw) > 2 * 1024 * 1024: return "invalid_response"
377
+ payload = json.loads(raw)
378
+ data = payload.get("data") if isinstance(payload, dict) else None
379
+ if router:
380
+ # Do not return the key label, account metadata or spend.
381
+ return "verified" if isinstance(data, dict) and isinstance(data.get("label"), str) else "invalid_response"
382
+ if not isinstance(data, list) or not all(isinstance(item, dict) and isinstance(item.get("id"), str) for item in data):
383
+ return "invalid_response"
384
+ # A gateway may expose its catalog publicly. Confirm this route
385
+ # actually distinguishes credentials before showing a green check.
386
+ async with client.stream("GET", target, headers={"Accept": "application/json", "anthropic-version": "2023-06-01"}) as public:
387
+ return "verified" if public.status_code in {401, 403} else "model_required"
388
+ try:
389
+ return await asyncio.wait_for(send(), timeout=20)
390
+ except (asyncio.TimeoutError, httpx.HTTPError):
391
+ return "connection_failed"
392
+ except (ValueError, TypeError, AttributeError, RecursionError):
393
+ return "invalid_response"
394
+
395
+
396
+ async def probe(selected: dict) -> str:
397
+ """One small, explicit model request; no tools/history, redirects or retries."""
398
+ if not selected.get("model"):
399
+ return await probe_credentials(selected)
400
+ protocol = selected["protocol"]
401
+ headers = {"Content-Type": "application/json"}
402
+ if selected["auth_header"] == "x-api-key":
403
+ headers["x-api-key"] = selected["api_key"]
404
+ else:
405
+ headers["Authorization"] = "Bearer " + selected["api_key"]
406
+ base = selected["base_url"]
407
+ prompt = "Connection check. Reply with OK only."
408
+ if protocol == "anthropic":
409
+ suffix = "/messages" if base.endswith("/v1") else "/v1/messages"
410
+ headers["anthropic-version"] = "2023-06-01"
411
+ body = {"model": selected["model"], "max_tokens": 32, "messages": [{"role": "user", "content": prompt}]}
412
+ elif protocol == "responses":
413
+ suffix = "/responses"
414
+ body = {"model": selected["model"], "max_output_tokens": 64, "input": prompt, "store": False}
415
+ else:
416
+ suffix = "/chat/completions"
417
+ body = {"model": selected["model"], "max_tokens": 32, "messages": [{"role": "user", "content": prompt}]}
418
+ async def send():
419
+ async with httpx.AsyncClient(timeout=15, follow_redirects=False, trust_env=False) as client:
420
+ async with client.stream("POST", base + suffix, headers=headers, json=body) as response:
421
+ if response.status_code in {401, 403}:
422
+ return "authentication_failed"
423
+ if response.status_code == 429:
424
+ return "rate_limited"
425
+ if response.status_code != 200:
426
+ return "unsupported" if response.status_code < 500 else "connection_failed"
427
+ raw = bytearray()
428
+ async for chunk in response.aiter_bytes():
429
+ raw.extend(chunk)
430
+ if len(raw) > 128 * 1024:
431
+ return "invalid_response"
432
+ value = json.loads(raw)
433
+ if protocol == "anthropic":
434
+ valid = value.get("type") == "message" and any(block.get("type") == "text" and block.get("text", "").strip() for block in value.get("content", []))
435
+ elif protocol == "responses":
436
+ valid = value.get("status") == "completed" and any(block.get("type") == "output_text" and block.get("text", "").strip()
437
+ for item in value.get("output", []) for block in item.get("content", []))
438
+ else:
439
+ valid = any(item.get("message", {}).get("content", "").strip() for item in value.get("choices", []))
440
+ return "verified" if valid else "invalid_response"
441
+ try:
442
+ return await asyncio.wait_for(send(), timeout=20)
443
+ except (asyncio.TimeoutError, httpx.HTTPError):
444
+ return "connection_failed"
445
+ except (ValueError, TypeError, AttributeError, RecursionError):
446
+ return "invalid_response"
447
+
448
+
449
+ def native_account_metadata(backend: str, *, env=None, cursor_executable=None, command=None) -> dict:
450
+ """Allowlisted account display fields only; never log in or renew credentials.
451
+
452
+ Claude's profile is cached metadata, not proof of current authentication.
453
+ OpenCode can hold several providers and has no single email/subscription.
454
+ """
455
+ if backend not in {"claude", "cursor", "opencode"}:
456
+ raise HTTPException(400, "Unsupported CLI account.")
457
+ env = os.environ if env is None else env
458
+ result = {"backend": backend, "email": None, "plan_type": None, "source": "unavailable"}
459
+
460
+ def clean(value, *, email=False):
461
+ if not isinstance(value, str) or not 1 <= len(value) <= (254 if email else 80):
462
+ return None
463
+ if any(ord(c) < 32 or ord(c) == 127 for c in value):
464
+ return None
465
+ if email and not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", value):
466
+ return None
467
+ return value
468
+
469
+ def document(path, limit):
470
+ try:
471
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
472
+ with os.fdopen(fd, "rb") as stream:
473
+ info = os.fstat(stream.fileno())
474
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o022:
475
+ return {}
476
+ raw = stream.read(limit + 1)
477
+ value = json.loads(raw) if len(raw) <= limit else {}
478
+ return value if isinstance(value, dict) else {}
479
+ except (OSError, ValueError, RecursionError):
480
+ return {}
481
+
482
+ if backend == "claude":
483
+ # Environment credentials may represent an entirely different account.
484
+ if any(env.get(key) for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
485
+ "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY")):
486
+ return result
487
+ root = Path(env.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
488
+ profile = document(root / ".claude.json" if env.get("CLAUDE_CONFIG_DIR") else Path.home() / ".claude.json", 2 * 1024 * 1024)
489
+ account = profile.get("oauthAccount")
490
+ oauth = document(root / ".credentials.json", 65536).get("claudeAiOauth")
491
+ if isinstance(account, dict):
492
+ result["email"] = clean(account.get("emailAddress"), email=True)
493
+ if isinstance(oauth, dict) and oauth.get("accessToken"):
494
+ result["plan_type"] = clean(oauth.get("subscriptionType"))
495
+ if result["email"] or result["plan_type"]:
496
+ result["source"] = "local_profile"
497
+ elif backend == "cursor" and cursor_executable and command and not env.get("CURSOR_API_KEY"):
498
+ from cursor_agent_client import parse_cursor_auth_status, parse_cursor_account_tier
499
+ try:
500
+ status = command([cursor_executable, "status"])
501
+ if status.returncode != 0:
502
+ return result
503
+ parsed = parse_cursor_auth_status(status.stdout + "\n" + status.stderr, executable_name=Path(cursor_executable).name)
504
+ if parsed.get("state") != "ready":
505
+ return result
506
+ result["email"] = clean(parsed.get("email"), email=True)
507
+ result["source"] = "cli"
508
+ about = command([cursor_executable, "about"])
509
+ if about.returncode == 0:
510
+ result["plan_type"] = clean(parse_cursor_account_tier(about.stdout + "\n" + about.stderr))
511
+ except (OSError, subprocess.SubprocessError):
512
+ pass
513
+ return result
514
+
515
+
516
+ def create_router(*, authorize, store: ConnectionStore, check=probe, account=native_account_metadata,
517
+ codex_store=None, codex_set_model=None, session_lookup=None, cursor_catalog=None) -> APIRouter:
518
+ router = APIRouter()
519
+ locks = {name: asyncio.Lock() for name in PROTOCOLS}
520
+
521
+ async def body(request: Request) -> dict:
522
+ if request.headers.get("content-type", "").split(";")[0] != "application/json":
523
+ raise HTTPException(415, "JSON is required.")
524
+ raw = bytearray()
525
+ async def read():
526
+ async for chunk in request.stream():
527
+ raw.extend(chunk)
528
+ if len(raw) > MAX_BODY_BYTES:
529
+ raise HTTPException(413, "Endpoint request is too large.")
530
+ try:
531
+ await asyncio.wait_for(read(), timeout=10)
532
+ value = json.loads(raw)
533
+ if not isinstance(value, dict):
534
+ raise ValueError()
535
+ return value
536
+ except asyncio.TimeoutError:
537
+ raise HTTPException(408, "Endpoint request timed out.") from None
538
+ except (ValueError, RecursionError):
539
+ raise HTTPException(400, "Invalid endpoint request.") from None
540
+ finally:
541
+ raw.clear()
542
+
543
+ def reply(value):
544
+ return JSONResponse(value, headers={"Cache-Control": "no-store"})
545
+
546
+ async def model_selection(backend, session_id=None):
547
+ if backend not in {"codex", "claude", "opencode", "cursor"}:
548
+ raise HTTPException(400, "Unsupported custom endpoint.")
549
+ session = session_lookup(session_id) if session_id and session_lookup else None
550
+ if session_id and (not session or session.get("backend") != backend):
551
+ raise HTTPException(404, "Chat not found.")
552
+ if backend == "codex":
553
+ if codex_store is None: raise HTTPException(501, "Custom model settings are unavailable.")
554
+ selected = await asyncio.to_thread(codex_store.for_session, session, include_key=True) if session else await asyncio.to_thread(codex_store.selection, include_key=True, include_revision=True)
555
+ else:
556
+ selected = await asyncio.to_thread(store.for_session, session) if session else await asyncio.to_thread(store.read, backend)
557
+ if not selected or not selected.get("api_key"):
558
+ raise HTTPException(409, "Connect a custom API first.")
559
+ return selected
560
+
561
+ @router.get("/api/admin/provider-models/{backend}")
562
+ async def models(backend: str, request: Request, session_id: str | None = None):
563
+ authorize(request)
564
+ selected = await model_selection(backend, session_id)
565
+ if backend == "cursor":
566
+ if cursor_catalog is None: raise HTTPException(501, "Cursor API keys are unavailable.")
567
+ result = await cursor_catalog(selected)
568
+ else:
569
+ result = await asyncio.to_thread(endpoint_model_catalog, selected, protocol=selected.get("protocol", "responses"))
570
+ # Fence a settings read that raced replacement/forget. Session bindings
571
+ # deliberately remain usable independently of the current settings.
572
+ if not session_id:
573
+ current = await model_selection(backend)
574
+ if (current.get("credential_id"), current.get("revision")) != (selected.get("credential_id"), selected.get("revision")):
575
+ raise HTTPException(409, "Endpoint settings changed. Refresh and try again.")
576
+ if backend == "codex":
577
+ codex_store.cache_catalog(selected, result)
578
+ else:
579
+ with store.lock:
580
+ store.catalog_cache[(backend, selected["revision"])] = (time.monotonic(), result["models"])
581
+ if len(store.catalog_cache) > 32: store.catalog_cache.pop(next(iter(store.catalog_cache)))
582
+ return reply({"backend": backend, "revision": selected.get("credential_id") if backend == "codex" else selected["revision"],
583
+ "models": result["models"], "default_model": selected.get("model"), "discovery_status": result["discovery_status"]})
584
+
585
+ @router.put("/api/admin/provider-models/{backend}")
586
+ async def default_model(backend: str, request: Request):
587
+ authorize(request)
588
+ payload = await body(request)
589
+ if set(payload) != {"model", "expected_revision"}: raise HTTPException(400, "Provide model and saved revision only.")
590
+ model = validate_model(payload["model"]) if payload["model"] is not None else None
591
+ selected = await model_selection(backend)
592
+ if backend == "codex":
593
+ if not codex_set_model: raise HTTPException(501, "Custom model settings are unavailable.")
594
+ return reply(await codex_set_model(model, payload["expected_revision"]))
595
+ async with locks[backend]:
596
+ # No new credential check: only the default changes, and the previous
597
+ # check time/result must remain honest. Existing snapshots are untouched.
598
+ def save_model():
599
+ with store.lock:
600
+ current = store.read(backend)
601
+ if current["revision"] != revision(payload["expected_revision"]):
602
+ raise HTTPException(409, "Endpoint settings changed. Refresh and try again.")
603
+ if current.get("last_result") != "verified": raise HTTPException(409, "Check this API connection first.")
604
+ value = {key: current[key] for key in ("base_url", "api_key", "protocol", "auth_header")}
605
+ saved = store.write(backend, current["revision"], {**value, "model": model, "expected_revision": current["revision"]}, current["last_result"], checked_at=current["checked_at"])
606
+ cached = store.catalog_cache.get((backend, current["revision"]))
607
+ if cached: store.catalog_cache[(backend, saved["revision"])] = cached
608
+ return {"backend": backend, "revision": saved["revision"], "default_model": saved["model"]}
609
+ return reply(await asyncio.to_thread(save_model))
610
+
611
+ @router.get("/api/admin/provider-accounts/{backend}")
612
+ async def native_account(backend: str, request: Request):
613
+ authorize(request)
614
+ if backend not in {"claude", "cursor", "opencode"}:
615
+ raise HTTPException(400, "Unsupported CLI account.")
616
+ return reply(await asyncio.to_thread(account, backend))
617
+
618
+ @router.get("/api/admin/provider-connections/{backend}")
619
+ async def status(backend: str, request: Request):
620
+ authorize(request)
621
+ backend_name(backend)
622
+ return reply(await asyncio.to_thread(store.public, backend))
623
+
624
+ @router.put("/api/admin/provider-connections/{backend}")
625
+ @router.delete("/api/admin/provider-connections/{backend}")
626
+ @router.post("/api/admin/provider-connections/{backend}/check")
627
+ async def change(backend: str, request: Request):
628
+ authorize(request)
629
+ backend_name(backend)
630
+ payload = await body(request)
631
+ expected = revision(payload.get("expected_revision"))
632
+ if locks[backend].locked():
633
+ raise HTTPException(409, "An endpoint operation is already in progress.")
634
+ async with locks[backend]:
635
+ current = await asyncio.to_thread(store.read, backend)
636
+ if current["revision"] != expected:
637
+ raise HTTPException(409, "Endpoint settings changed. Refresh and try again.")
638
+ if request.method != "PUT" and set(payload) != {"expected_revision"}:
639
+ raise HTTPException(400, "Only the saved revision is accepted.")
640
+ if request.method == "DELETE":
641
+ return reply(await asyncio.to_thread(store.write, backend, expected, None))
642
+ selected = selection(backend, payload) if request.method == "PUT" else (
643
+ {key: current[key] for key in ("base_url", "api_key", "model", "protocol", "auth_header", "expected_revision")}
644
+ if current["configured"] else None)
645
+ if selected is None:
646
+ raise HTTPException(409, "Save an endpoint before checking it.")
647
+ try:
648
+ if backend == "cursor":
649
+ if cursor_catalog is None: raise HTTPException(501, "Cursor API keys are unavailable.")
650
+ discovered = await cursor_catalog(selected)
651
+ result = "cli_update_required" if discovered.get("cli_update_required") else "verified" if discovered["discovery_status"] == "ready" and discovered["models"] else "connection_failed"
652
+ else:
653
+ result = await check(selected)
654
+ if request.method == "PUT" and result != "verified":
655
+ return reply({"ok": False, "status": result})
656
+ configuration = await asyncio.to_thread(store.write, backend, expected, selected, result)
657
+ if backend == "cursor" and result == "verified":
658
+ store.catalog_cache[(backend, configuration["revision"])] = (time.monotonic(), discovered["models"])
659
+ return reply({"ok": result == "verified", "status": result, "configuration": configuration})
660
+ finally:
661
+ selected.clear()
662
+ current.clear()
663
+ payload.clear()
664
+
665
+ return router