@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.
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
@@ -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 and not metadata:
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
- selected = self.selection(include_revision=True)
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": cached.get("default_model") or (selected or {}).get("model") or "",
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 and default_model not in capabilities:
442
- raise ValueError("Invalid saved default model")
443
- result = {"models": models, "model_capabilities": capabilities, "default_model": default_model}
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._atomic(credential_name, {**selected, "binding": binding(selected)})
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._atomic("settings.json", {})
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 discover_models(selected: dict, native_models: dict | None = None) -> dict:
721
- """One bounded operator-requested listing; never forward a key on redirect."""
722
- request = URLRequest(selected["base_url"] + "/models", headers={
723
- "Authorization": "Bearer " + selected["api_key"], "Accept": "application/json"})
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 build_opener(ProxyHandler({}), _NoModelRedirects()).open(request, timeout=15) as response:
726
- raw = response.read(1024 * 1024 + 1)
727
- if len(raw) > 1024 * 1024:
728
- raise ValueError("model response too large")
729
- payload = json.loads(raw)
730
- entries = payload.get("data") if isinstance(payload, dict) else None
731
- if not isinstance(entries, list):
732
- raise ValueError("invalid model list")
733
- models = []
734
- capabilities = {}
735
- seen = set()
736
- for entry in entries[:2000]:
737
- if not isinstance(entry, dict):
738
- continue
739
- try:
740
- value = validate_model(entry.get("id"))
741
- except HTTPException:
742
- continue
743
- capability = discovered_model_capability(entry, value, native_models)
744
- if capability is None:
745
- continue
746
- if value not in seen:
747
- seen.add(value)
748
- models.append({"value": value, "label": value})
749
- capabilities[value] = capability
750
- if len(models) == 512:
751
- break
752
- return {"ok": True, "status": "ready",
753
- "message": "The endpoint returned its model list. Model compatibility remains unverified until tested.",
754
- "models": models, "efforts": [],
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 capabilities.items()},
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
- return validate_selection(parsed)
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
- # Native metadata is local and credential-free; endpoint discovery stays
1031
- # explicit and supplies the identity/ownership fence for any fallback.
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
- return JSONResponse(await asyncio.to_thread(store.status, available=available()), headers={"Cache-Control": "no-store"})
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
- return JSONResponse(await asyncio.to_thread(store.status), headers={"Cache-Control": "no-store"})
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
- return JSONResponse(await asyncio.to_thread(store.status), headers={"Cache-Control": "no-store"})
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: