@agentsdock/server 1.0.7-beta.5 → 1.0.8
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 +20 -6
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +5283 -698
- package/server/agentsdock_chats.py +2 -1
- package/server/chat_mailbox.py +23 -6
- package/server/claude_history_repair.py +45 -4
- package/server/claude_model_catalog.py +200 -0
- package/server/claude_sdk_client.py +63 -24
- package/server/claude_side_question.py +1 -8
- package/server/codex_app_server.py +90 -30
- package/server/codex_auth.py +119 -0
- package/server/codex_history_repair.py +79 -14
- package/server/codex_provider.py +160 -77
- package/server/codex_side_question.py +154 -57
- package/server/cursor_agent_client.py +27 -1
- package/server/cursor_api_key.py +64 -0
- package/server/cursor_history.py +219 -0
- package/server/cursor_provider_mcp.py +290 -0
- package/server/execution_durability.py +10 -6
- package/server/execution_preparation.py +6 -2
- package/server/install.sh +282 -78
- package/server/instances.sh +15 -0
- package/server/interactive_chat_native.py +4 -3
- package/server/interactive_chat_projection.py +6 -19
- package/server/interactive_chat_share_routes.py +71 -45
- package/server/interactive_chat_share_web.py +2 -2
- package/server/interactive_chat_shares.py +6 -11
- package/server/local_session_ownership.py +232 -0
- package/server/opencode_agent_client.py +685 -0
- package/server/provider_commands.py +579 -0
- package/server/provider_connections.py +665 -0
- package/server/provider_usage.py +241 -0
- package/server/public_chat_shares.py +60 -62
- package/server/public_chat_transcript.py +9 -31
- package/server/pyproject.toml +1 -0
- package/server/server_instances.py +931 -0
- package/server/shared_chat_video_stream.py +21 -2
- package/server/shared_chat_videos.py +54 -24
- package/server/side_questions.py +493 -10
- package/server/title_generation.py +4 -1
- package/server/uninstall.sh +95 -17
- package/server/update_preparation.py +4 -1
- package/server/update_runner.py +16 -0
- package/server/uv.lock +3 -1
package/server/codex_provider.py
CHANGED
|
@@ -12,6 +12,8 @@ import stat
|
|
|
12
12
|
import tempfile
|
|
13
13
|
import threading
|
|
14
14
|
import uuid
|
|
15
|
+
import time
|
|
16
|
+
import httpx
|
|
15
17
|
from urllib.parse import urlsplit, urlunsplit
|
|
16
18
|
from urllib.error import HTTPError, URLError
|
|
17
19
|
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request as URLRequest, build_opener
|
|
@@ -21,7 +23,7 @@ from fastapi.responses import JSONResponse
|
|
|
21
23
|
|
|
22
24
|
from codex_auth import validate_api_key
|
|
23
25
|
from codex_app_server import CodexAppServerManager, decline_server_request
|
|
24
|
-
from codex_side_question import isolated_config, _verify_protocol
|
|
26
|
+
from codex_side_question import isolated_config, _verify_isolated_protocol as _verify_protocol
|
|
25
27
|
from side_questions import isolated_environment, run_isolated_command
|
|
26
28
|
|
|
27
29
|
|
|
@@ -267,7 +269,7 @@ class ProviderStore:
|
|
|
267
269
|
try:
|
|
268
270
|
metadata = self._read("settings.json") or {}
|
|
269
271
|
identifier = revision or metadata.get("credential_id")
|
|
270
|
-
if not identifier
|
|
272
|
+
if not identifier:
|
|
271
273
|
return None
|
|
272
274
|
if not isinstance(identifier, str) or not re.fullmatch(r"[0-9a-f]{32}", identifier):
|
|
273
275
|
raise ValueError("invalid credential identity")
|
|
@@ -282,6 +284,12 @@ class ProviderStore:
|
|
|
282
284
|
if not credential.get("binding") or credential.get("binding") not in (binding(selected), legacy_binding(selected)):
|
|
283
285
|
raise ValueError("credential binding mismatch")
|
|
284
286
|
key = validate_api_key({"api_key": credential.get("api_key")})
|
|
287
|
+
if self._epoch(credential) < self._epoch(metadata, settings=True):
|
|
288
|
+
current_id = metadata.get("credential_id")
|
|
289
|
+
current = self._read("credential-" + current_id + ".json") if isinstance(current_id, str) and re.fullmatch(r"[0-9a-f]{32}", current_id) else None
|
|
290
|
+
if not (current and metadata.get("connection_verified") is True
|
|
291
|
+
and selected["base_url"] == metadata.get("base_url") and key == current.get("api_key")):
|
|
292
|
+
raise HTTPException(409, "This chat's API connection is disconnected. Reconnect its original endpoint and API key in My Agents, then retry.")
|
|
285
293
|
self._public_selections[identifier] = {**selected, "credential_id": identifier}
|
|
286
294
|
self._revision_catalog_keys[identifier] = catalog_key({**selected, "api_key": key})
|
|
287
295
|
if include_key:
|
|
@@ -291,16 +299,32 @@ class ProviderStore:
|
|
|
291
299
|
if not include_revision and revision is None:
|
|
292
300
|
selected.pop("legacy_model", None)
|
|
293
301
|
return selected
|
|
302
|
+
except HTTPException as exc:
|
|
303
|
+
if exc.status_code == 409:
|
|
304
|
+
raise
|
|
305
|
+
raise HTTPException(503, "Saved provider settings or credentials are unavailable.") from None
|
|
294
306
|
except Exception:
|
|
295
307
|
raise HTTPException(503, "Saved provider settings or credentials are unavailable.") from None
|
|
296
308
|
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _epoch(record: dict, *, settings=False) -> int:
|
|
311
|
+
# Empty legacy settings mean Forget already happened. Existing legacy
|
|
312
|
+
# credential files belong to epoch zero and cannot be resurrected.
|
|
313
|
+
value = record.get("connection_epoch", 1 if settings and not record.get("credential_id") else 0)
|
|
314
|
+
if type(value) is not int or not 0 <= value < 2**53 - 1:
|
|
315
|
+
raise ValueError("invalid connection epoch")
|
|
316
|
+
return value
|
|
317
|
+
|
|
297
318
|
def status(self, *, available=True) -> dict:
|
|
298
|
-
|
|
319
|
+
with self.lock:
|
|
320
|
+
selected = self.selection(include_revision=True)
|
|
321
|
+
metadata = self._read("settings.json") or {}
|
|
322
|
+
evidence = {"connection_verified": True} if selected and metadata.get("credential_id") == selected.get("credential_id") and metadata.get("connection_verified") is True else {}
|
|
299
323
|
return {"available": available, "configured": selected is not None,
|
|
300
324
|
"base_url": selected["base_url"] if selected else None,
|
|
301
325
|
"model": selected.get("model") if selected else None,
|
|
302
326
|
"credential_id": selected.get("credential_id") if selected else None,
|
|
303
|
-
"has_api_key": selected is not None, "wire_api": "responses"}
|
|
327
|
+
"has_api_key": selected is not None, "wire_api": "responses", **evidence}
|
|
304
328
|
|
|
305
329
|
def for_session(self, session: dict, *, include_key=False) -> dict | None:
|
|
306
330
|
if session_choice(session.get("codex_provider")) == "default":
|
|
@@ -312,6 +336,11 @@ class ProviderStore:
|
|
|
312
336
|
if session.get("codex_provider_binding") not in (None, binding(selected), legacy_binding(selected)):
|
|
313
337
|
raise HTTPException(409, "This conversation belongs to another Codex endpoint. Start a new Codex chat, or restore its original endpoint.")
|
|
314
338
|
model = session.get("model") or selected.get("model") or self.cached_catalog(selected).get("default_model")
|
|
339
|
+
if not model and (session.get("codex_thread_id") or session.get("session_id")):
|
|
340
|
+
# Preserve the old inferred choice for an already-started thread;
|
|
341
|
+
# never offer that arbitrary first-row default to new chats.
|
|
342
|
+
previous = self._saved_model_catalog(selected)
|
|
343
|
+
model = previous.get("legacy_default_model") or previous.get("default_model")
|
|
315
344
|
if model:
|
|
316
345
|
selected["model"] = validate_model(model)
|
|
317
346
|
return selected
|
|
@@ -330,6 +359,9 @@ class ProviderStore:
|
|
|
330
359
|
# metadata without reopening credential files for every event.
|
|
331
360
|
selected = self._public_selections.get(session.get("codex_provider_revision")) if session else None
|
|
332
361
|
selected = selected or (self.for_session(session) if session is not None else self.registration())
|
|
362
|
+
if selected and session and not selected.get("model") and (session.get("codex_thread_id") or session.get("session_id")):
|
|
363
|
+
previous = self._saved_model_catalog(selected)
|
|
364
|
+
selected = {**selected, "model": session.get("model") or previous.get("legacy_default_model") or previous.get("default_model")}
|
|
333
365
|
except HTTPException:
|
|
334
366
|
selected = None
|
|
335
367
|
result = {"configured": selected is not None, "available": available and selected is not None,
|
|
@@ -367,15 +399,17 @@ class ProviderStore:
|
|
|
367
399
|
"model_efforts": {model: [option for option in EFFORT_OPTIONS if option["value"] in capability["reasoning_efforts"]]
|
|
368
400
|
for model, capability in capabilities.items()},
|
|
369
401
|
"model_capabilities": capabilities,
|
|
370
|
-
"default_model":
|
|
402
|
+
"default_model": (selected or {}).get("model") or "",
|
|
371
403
|
"default_effort": ""}
|
|
372
404
|
|
|
373
405
|
def cache_catalog(self, selected: dict, catalog: dict):
|
|
374
406
|
with self.lock:
|
|
407
|
+
previous = self._saved_model_catalog(selected)
|
|
408
|
+
legacy_default = previous.get("legacy_default_model") or (previous.get("default_model") if not selected.get("model") else None)
|
|
375
409
|
models = catalog.get("models", [])
|
|
376
410
|
self._catalogs[catalog_key(selected)] = {"models": models,
|
|
377
411
|
"model_capabilities": {model["value"]: model_capability(catalog.get("model_capabilities", {}).get(model["value"])) for model in models},
|
|
378
|
-
"default_model": catalog.get("default_model", "")}
|
|
412
|
+
"default_model": catalog.get("default_model", ""), "legacy_default_model": legacy_default}
|
|
379
413
|
self._save_model_catalog(selected, self._catalogs[catalog_key(selected)])
|
|
380
414
|
# Apply fresh explicit denials when discovery completes, rather
|
|
381
415
|
# than letting old metadata veto a later successful model check.
|
|
@@ -430,7 +464,7 @@ class ProviderStore:
|
|
|
430
464
|
or not isinstance(catalog, dict) or not isinstance(catalog.get("models"), list)
|
|
431
465
|
or len(catalog["models"]) > 512 or not isinstance(catalog.get("model_capabilities"), dict)):
|
|
432
466
|
raise ValueError("Invalid saved model catalog")
|
|
433
|
-
models = [{"value": validate_model(model.get("value")), "label": validate_model(model.get("value"))}
|
|
467
|
+
models = [{"value": validate_model(model.get("value")), "label": safe_model_label(model.get("label"), validate_model(model.get("value")))}
|
|
434
468
|
for model in catalog["models"]]
|
|
435
469
|
if len({model["value"] for model in models}) != len(models):
|
|
436
470
|
raise ValueError("Duplicate saved model identities")
|
|
@@ -438,9 +472,11 @@ class ProviderStore:
|
|
|
438
472
|
**catalog["model_capabilities"].get(model["value"], {}),
|
|
439
473
|
"compatibility": "unverified", "reasoning_summary_supported": None}) for model in models}
|
|
440
474
|
default_model = catalog.get("default_model") or ""
|
|
441
|
-
if default_model
|
|
442
|
-
|
|
443
|
-
|
|
475
|
+
if default_model:
|
|
476
|
+
default_model = validate_model(default_model)
|
|
477
|
+
legacy = catalog.get("legacy_default_model")
|
|
478
|
+
result = {"models": models, "model_capabilities": capabilities, "default_model": default_model,
|
|
479
|
+
"legacy_default_model": validate_model(legacy) if legacy else None}
|
|
444
480
|
self._saved_catalogs[identifier] = result
|
|
445
481
|
return result
|
|
446
482
|
except Exception:
|
|
@@ -557,8 +593,9 @@ class ProviderStore:
|
|
|
557
593
|
"""Make the current generation independently readable before replacement."""
|
|
558
594
|
selected = self.selection(include_key=True, include_revision=True)
|
|
559
595
|
if selected:
|
|
596
|
+
previous = self._read("credential-" + selected["credential_id"] + ".json") or {}
|
|
560
597
|
self._atomic("credential-" + selected["credential_id"] + ".json",
|
|
561
|
-
{**selected, "binding": binding(selected)})
|
|
598
|
+
{**selected, "binding": binding(selected), "connection_epoch": self._epoch(previous)})
|
|
562
599
|
for path in self.root.glob("thread-*.json"):
|
|
563
600
|
previous = self._read(path.name) or {}
|
|
564
601
|
if not previous.get("credential_id") and previous.get("binding") and previous["binding"] in (binding(selected), legacy_binding(selected)):
|
|
@@ -575,10 +612,14 @@ class ProviderStore:
|
|
|
575
612
|
pass
|
|
576
613
|
identifier = uuid.uuid4().hex
|
|
577
614
|
credential_name = "credential-" + identifier + ".json"
|
|
578
|
-
self.
|
|
615
|
+
epoch = self._epoch(self._read("settings.json") or {}, settings=True)
|
|
616
|
+
self._atomic(credential_name, {**selected, "binding": binding(selected), "connection_epoch": epoch})
|
|
579
617
|
try:
|
|
580
618
|
self._atomic("settings.json", {name: value for name, value in
|
|
581
|
-
{**selected, "credential_id": identifier}.items() if name != "api_key"})
|
|
619
|
+
{**selected, "credential_id": identifier, "connection_epoch": epoch}.items() if name != "api_key"})
|
|
620
|
+
# Restored old revisions are authorized by the current verified
|
|
621
|
+
# identity; a later replacement must not reuse cached readiness.
|
|
622
|
+
self._public_selections.clear()
|
|
582
623
|
except BaseException:
|
|
583
624
|
with suppress(OSError):
|
|
584
625
|
os.unlink(self.root / credential_name)
|
|
@@ -590,7 +631,9 @@ class ProviderStore:
|
|
|
590
631
|
self.retain_current()
|
|
591
632
|
except (HTTPException, ValueError, UnicodeError):
|
|
592
633
|
pass
|
|
593
|
-
self.
|
|
634
|
+
epoch = self._epoch(self._read("settings.json") or {}, settings=True)
|
|
635
|
+
self._atomic("settings.json", {"connection_epoch": epoch + 1})
|
|
636
|
+
self._public_selections.clear()
|
|
594
637
|
|
|
595
638
|
|
|
596
639
|
def native_config(selected: dict) -> dict:
|
|
@@ -717,56 +760,80 @@ class _NoModelRedirects(HTTPRedirectHandler):
|
|
|
717
760
|
return None
|
|
718
761
|
|
|
719
762
|
|
|
720
|
-
def
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
763
|
+
def safe_model_label(label, model):
|
|
764
|
+
return label.strip() if isinstance(label, str) and label.strip() and len(label) <= 160 and all(ord(c) >= 32 and ord(c) != 127 for c in label) else model
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def endpoint_model_catalog(selected: dict, *, protocol="responses") -> dict:
|
|
768
|
+
"""Read only this endpoint's inventory. Never use CLI models or infer a default.
|
|
769
|
+
|
|
770
|
+
Pagination stays on the original URL; arbitrary next links are never followed.
|
|
771
|
+
Discovery is not an inference/credential verification or a compatibility test.
|
|
772
|
+
"""
|
|
773
|
+
base = selected["base_url"].rstrip("/")
|
|
774
|
+
parsed = urlsplit(base)
|
|
775
|
+
router = parsed.scheme == "https" and parsed.netloc in {"openrouter.ai", "eu.openrouter.ai"} and parsed.path in {"/api", "/api/v1"}
|
|
776
|
+
target = (base.removesuffix("/v1") + "/v1/models/user") if router else base + (
|
|
777
|
+
"/v1/models" if protocol == "anthropic" and not base.endswith("/v1") else "/models")
|
|
778
|
+
headers = {"Accept": "application/json", "anthropic-version": "2023-06-01"}
|
|
779
|
+
header = selected.get("auth_header", "bearer")
|
|
780
|
+
headers["x-api-key" if header == "x-api-key" else "Authorization"] = selected["api_key"] if header == "x-api-key" else "Bearer " + selected["api_key"]
|
|
781
|
+
options, capabilities, seen_cursors = [], {}, set()
|
|
782
|
+
total_bytes, deadline, after = 0, time.monotonic() + 10, None
|
|
783
|
+
def result(status):
|
|
784
|
+
return {"models": options, "model_capabilities": capabilities, "discovery_status": status,
|
|
785
|
+
"default_model": selected.get("model") or "", "efforts": [], "default_effort": ""}
|
|
724
786
|
try:
|
|
725
|
-
with
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
787
|
+
with httpx.Client(timeout=3, follow_redirects=False, trust_env=False) as client:
|
|
788
|
+
for page in range(5):
|
|
789
|
+
remaining = deadline - time.monotonic()
|
|
790
|
+
if remaining <= 0: return result("partial")
|
|
791
|
+
params = {"limit": 1000} if protocol == "anthropic" and not router else {}
|
|
792
|
+
if after: params["after_id" if protocol == "anthropic" and not router else "after"] = after
|
|
793
|
+
with client.stream("GET", target, headers=headers, params=params, timeout=min(3, remaining)) as response:
|
|
794
|
+
if response.status_code != 200:
|
|
795
|
+
if response.status_code in {401, 403}:
|
|
796
|
+
options.clear(); capabilities.clear()
|
|
797
|
+
return result("authentication_failed")
|
|
798
|
+
return result("partial" if options else "unavailable")
|
|
799
|
+
raw = bytearray()
|
|
800
|
+
for chunk in response.iter_bytes():
|
|
801
|
+
total_bytes += len(chunk)
|
|
802
|
+
if total_bytes > 8 * 1024 * 1024 or time.monotonic() > deadline: return result("partial" if options else "unavailable")
|
|
803
|
+
raw.extend(chunk)
|
|
804
|
+
payload = json.loads(raw)
|
|
805
|
+
entries = payload.get("data") if isinstance(payload, dict) else None
|
|
806
|
+
if not isinstance(entries, list): return result("partial" if options else "unavailable")
|
|
807
|
+
for item in entries[:2000]:
|
|
808
|
+
if not isinstance(item, dict): continue
|
|
809
|
+
try: model = validate_model(item.get("id"))
|
|
810
|
+
except HTTPException: continue
|
|
811
|
+
capability = discovered_model_capability(item, model)
|
|
812
|
+
if capability is None or model in capabilities: continue
|
|
813
|
+
declared = item.get("capabilities") if isinstance(item.get("capabilities"), dict) else {}
|
|
814
|
+
if declared.get("tools") is False or item.get("supports_tools") is False: continue
|
|
815
|
+
params_supported = item.get("supported_parameters")
|
|
816
|
+
if isinstance(params_supported, list) and params_supported and "tools" not in params_supported: continue
|
|
817
|
+
label = safe_model_label(item.get("display_name") or item.get("name"), model)
|
|
818
|
+
options.append({"value": model, "label": label})
|
|
819
|
+
capabilities[model] = capability
|
|
820
|
+
if len(options) >= 512: return result("partial")
|
|
821
|
+
if payload.get("has_more") is not True: return result("ready" if options else "empty")
|
|
822
|
+
cursor = payload.get("last_id") or (entries[-1].get("id") if entries and isinstance(entries[-1], dict) else None)
|
|
823
|
+
if not isinstance(cursor, str) or not 1 <= len(cursor) <= 256 or cursor in seen_cursors: return result("partial")
|
|
824
|
+
seen_cursors.add(cursor); after = cursor
|
|
825
|
+
return result("partial")
|
|
826
|
+
except (httpx.HTTPError, ValueError, TypeError, RecursionError):
|
|
827
|
+
return result("partial" if options else "unavailable")
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def discover_models(selected: dict, native_models: dict | None = None) -> dict:
|
|
831
|
+
result = endpoint_model_catalog(selected)
|
|
832
|
+
ready = result["discovery_status"] in {"ready", "partial", "empty"}
|
|
833
|
+
return {**result, "ok": ready, "status": "ready" if ready else "authentication_failed" if result["discovery_status"] == "authentication_failed" else "failed",
|
|
834
|
+
"message": "Endpoint model inventory; model compatibility remains unverified until tested.",
|
|
755
835
|
"model_efforts": {model: [option for option in EFFORT_OPTIONS if option["value"] in capability["reasoning_efforts"]]
|
|
756
|
-
for model, capability in
|
|
757
|
-
"model_capabilities": capabilities,
|
|
758
|
-
"default_model": models[0]["value"] if models else "", "default_effort": ""}
|
|
759
|
-
except HTTPError as exc:
|
|
760
|
-
if exc.code in (401, 403):
|
|
761
|
-
return test_result("authentication_failed")
|
|
762
|
-
if exc.code in (404, 405):
|
|
763
|
-
return {**test_result("unsupported"),
|
|
764
|
-
"message": "The endpoint does not provide model discovery. Save it and enter a model ID in the chat."}
|
|
765
|
-
return test_result("failed")
|
|
766
|
-
except (URLError, TimeoutError, ConnectionError):
|
|
767
|
-
return test_result("connection_failed")
|
|
768
|
-
except Exception:
|
|
769
|
-
return test_result("failed")
|
|
836
|
+
for model, capability in result["model_capabilities"].items()}}
|
|
770
837
|
|
|
771
838
|
|
|
772
839
|
def test_result(status: str) -> dict:
|
|
@@ -982,7 +1049,7 @@ async def test_connection(selected: dict, *, executable: str, environment: dict,
|
|
|
982
1049
|
raise asyncio.CancelledError
|
|
983
1050
|
|
|
984
1051
|
|
|
985
|
-
def create_router(*, authorize, store: ProviderStore, mutate, probe, available, discover=discover_models, session_lookup=None, native_models=None) -> APIRouter:
|
|
1052
|
+
def create_router(*, authorize, store: ProviderStore, mutate, probe, available, discover=discover_models, session_lookup=None, native_models=None, check_credentials=None) -> APIRouter:
|
|
986
1053
|
router = APIRouter()
|
|
987
1054
|
|
|
988
1055
|
async def body(request, *, saved=False):
|
|
@@ -993,6 +1060,9 @@ def create_router(*, authorize, store: ProviderStore, mutate, probe, available,
|
|
|
993
1060
|
raise HTTPException(413, "Provider request is too large.")
|
|
994
1061
|
try:
|
|
995
1062
|
parsed = json.loads(raw)
|
|
1063
|
+
verify = isinstance(parsed, dict) and request.method == "PUT" and parsed.pop("verify_connection", False)
|
|
1064
|
+
if verify is not False and verify is not True:
|
|
1065
|
+
raise HTTPException(400, "Invalid connection check.")
|
|
996
1066
|
if saved and isinstance(parsed, dict) and "base_url" not in parsed and "api_key" not in parsed:
|
|
997
1067
|
if set(parsed) - {"model", "session_id", "credential_id"}:
|
|
998
1068
|
raise HTTPException(400, "Provide a model and the saved endpoint identity.")
|
|
@@ -1015,7 +1085,10 @@ def create_router(*, authorize, store: ProviderStore, mutate, probe, available,
|
|
|
1015
1085
|
selected.clear()
|
|
1016
1086
|
raise HTTPException(409, "The saved endpoint changed. Refresh before checking this model.")
|
|
1017
1087
|
return {**selected, "model": selected_model}
|
|
1018
|
-
|
|
1088
|
+
selected = validate_selection(parsed)
|
|
1089
|
+
if verify:
|
|
1090
|
+
selected["verify_connection"] = True
|
|
1091
|
+
return selected
|
|
1019
1092
|
except (ValueError, UnicodeError, RecursionError):
|
|
1020
1093
|
raise HTTPException(400, "Provide a valid provider request.") from None
|
|
1021
1094
|
finally:
|
|
@@ -1027,16 +1100,8 @@ def create_router(*, authorize, store: ProviderStore, mutate, probe, available,
|
|
|
1027
1100
|
raise HTTPException(503, "Custom providers require native Codex app-server transport.")
|
|
1028
1101
|
|
|
1029
1102
|
async def catalog(selected):
|
|
1030
|
-
#
|
|
1031
|
-
|
|
1032
|
-
if native_models is not None:
|
|
1033
|
-
try:
|
|
1034
|
-
native = await native_models()
|
|
1035
|
-
except (HTTPException, OSError, ValueError):
|
|
1036
|
-
native = None
|
|
1037
|
-
result = await asyncio.to_thread(discover, selected, native_models=native)
|
|
1038
|
-
else:
|
|
1039
|
-
result = await asyncio.to_thread(discover, selected)
|
|
1103
|
+
# Custom inventories must not depend on CLI login or its native catalog.
|
|
1104
|
+
result = await asyncio.to_thread(discover, selected)
|
|
1040
1105
|
if result.get("ok") is True:
|
|
1041
1106
|
store.cache_catalog(selected, result)
|
|
1042
1107
|
return {**result, **store.cached_catalog(selected)}
|
|
@@ -1045,7 +1110,10 @@ def create_router(*, authorize, store: ProviderStore, mutate, probe, available,
|
|
|
1045
1110
|
@router.get("/api/admin/codex/provider")
|
|
1046
1111
|
async def status(request: Request):
|
|
1047
1112
|
authorize(request)
|
|
1048
|
-
|
|
1113
|
+
configuration = await asyncio.to_thread(store.status, available=available())
|
|
1114
|
+
if check_credentials is not None:
|
|
1115
|
+
configuration["connection_check_available"] = True
|
|
1116
|
+
return JSONResponse(configuration, headers={"Cache-Control": "no-store"})
|
|
1049
1117
|
|
|
1050
1118
|
@router.post("/api/admin/codex/provider/test")
|
|
1051
1119
|
async def test(request: Request):
|
|
@@ -1097,8 +1165,20 @@ def create_router(*, authorize, store: ProviderStore, mutate, probe, available,
|
|
|
1097
1165
|
access(request)
|
|
1098
1166
|
selected = await body(request)
|
|
1099
1167
|
try:
|
|
1168
|
+
if selected.pop("verify_connection", False):
|
|
1169
|
+
if check_credentials is None:
|
|
1170
|
+
raise HTTPException(501, "Update the server to verify an API connection.")
|
|
1171
|
+
result = await check_credentials(selected)
|
|
1172
|
+
if result != "verified":
|
|
1173
|
+
raise HTTPException(400, "API connection check failed. Check the URL and key.")
|
|
1174
|
+
# Only this server-attested result may be persisted. It is not
|
|
1175
|
+
# an accepted field in client-supplied provider selections.
|
|
1176
|
+
selected["connection_verified"] = True
|
|
1100
1177
|
await mutate(selected)
|
|
1101
|
-
|
|
1178
|
+
configuration = await asyncio.to_thread(store.status)
|
|
1179
|
+
if check_credentials is not None:
|
|
1180
|
+
configuration["connection_check_available"] = True
|
|
1181
|
+
return JSONResponse(configuration, headers={"Cache-Control": "no-store"})
|
|
1102
1182
|
except HTTPException:
|
|
1103
1183
|
raise
|
|
1104
1184
|
except Exception:
|
|
@@ -1111,7 +1191,10 @@ def create_router(*, authorize, store: ProviderStore, mutate, probe, available,
|
|
|
1111
1191
|
access(request)
|
|
1112
1192
|
try:
|
|
1113
1193
|
await mutate(None)
|
|
1114
|
-
|
|
1194
|
+
configuration = await asyncio.to_thread(store.status)
|
|
1195
|
+
if check_credentials is not None:
|
|
1196
|
+
configuration["connection_check_available"] = True
|
|
1197
|
+
return JSONResponse(configuration, headers={"Cache-Control": "no-store"})
|
|
1115
1198
|
except HTTPException:
|
|
1116
1199
|
raise
|
|
1117
1200
|
except Exception:
|