@agentsdock/server 1.0.10-beta.1 → 1.0.10-beta.6

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.
@@ -12,7 +12,9 @@ from __future__ import annotations
12
12
 
13
13
  import argparse
14
14
  import base64
15
+ from contextlib import ExitStack
15
16
  import errno
17
+ import hashlib
16
18
  import json
17
19
  import os
18
20
  from pathlib import Path
@@ -25,6 +27,7 @@ import sys
25
27
  import time
26
28
  from typing import Any, Callable
27
29
  import urllib.parse
30
+ import uuid
28
31
 
29
32
  import execution_install as files
30
33
  from execution_transport import _read_secret
@@ -216,6 +219,35 @@ class NativeServices:
216
219
  raise RuntimeError("native service is transitioning; retry after it settles")
217
220
  return states
218
221
 
222
+ def validate_registered_bindings(self) -> None:
223
+ """Reject foreign or stale manager-loaded jobs without exposing output."""
224
+ for role in ("worker", "gateway"):
225
+ path = str(self.layout.service_path(role))
226
+ if self.layout.platform == "Linux":
227
+ result = self._command(["systemctl", "--user", "show", self._unit(role), "--no-pager",
228
+ "--property=FragmentPath,DropInPaths,NeedDaemonReload"])
229
+ values = {}
230
+ for line in result.stdout.splitlines():
231
+ key, separator, value = line.partition("=")
232
+ if not separator or key in values:
233
+ raise RuntimeError("systemd returned ambiguous registered bindings")
234
+ values[key] = value
235
+ if values != {"FragmentPath": path, "DropInPaths": "", "NeedDaemonReload": "no"}:
236
+ raise RuntimeError("native unit registration is foreign, overridden, or stale")
237
+ else:
238
+ result = self._command(["/bin/launchctl", "print", self._target(role)], allow_failure=True)
239
+ if result.returncode:
240
+ if "Could not find service" in result.stderr:
241
+ continue # bootout removed the job; exact plist is checked separately.
242
+ raise RuntimeError("launchd registration ownership is unknown")
243
+ paths = re.findall(r"(?m)^\s*path = (.+)$", result.stdout)
244
+ programs = re.findall(r"(?m)^\s*program = (.+)$", result.stdout)
245
+ arguments = re.findall(r"(?ms)^\s*arguments = \{\n(.*?)^\s*\}", result.stdout)
246
+ expected = files.service_arguments(self.layout, role)
247
+ if (paths != [path] or programs != [expected[0]] or len(arguments) != 1
248
+ or [line.strip() for line in arguments[0].splitlines() if line.strip()] != expected):
249
+ raise RuntimeError("native launchd registration does not match the installed service")
250
+
219
251
  def stop(self, role: str) -> None:
220
252
  state = self._observe(role)
221
253
  if state["state"] in {"absent", "stopped"}:
@@ -426,6 +458,278 @@ class WorkerControl:
426
458
  "maintenance_held": worker.get("maintenance_held")}
427
459
 
428
460
 
461
+ LIFECYCLE_NAME = ".execution-lifecycle.json"
462
+
463
+
464
+ def read_lifecycle_intent(layout: files.ExecutionLayout) -> dict | None:
465
+ """Read owned stop/restart evidence; never repair or normalize user state."""
466
+ from execution_preparation import _check_binding
467
+ try:
468
+ data, _mode = files._read_file(layout.install_root / LIFECYCLE_NAME, private=True)
469
+ except FileNotFoundError:
470
+ return None
471
+ value = json.loads(data)
472
+ expected = {"format", "operation_id", "layout_sha256", "identities", "server_identity",
473
+ "worker_pid", "gateway_pid", "worker_instance_id", "lease_id", "phase", "started"}
474
+ if (not isinstance(value, dict) or set(value) != expected or type(value["format"]) is not int
475
+ or value["format"] != 1 or value["phase"] not in {"prepared", "sealed", "stopped", "starting", "releasing"}
476
+ or not isinstance(value["operation_id"], str)
477
+ or str(uuid.UUID(value["operation_id"])) != value["operation_id"]
478
+ or any(type(value[key]) is not int or value[key] <= 0 for key in ("worker_pid", "gateway_pid"))
479
+ or any(not isinstance(value[key], str) or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", value[key]) is None
480
+ for key in ("server_identity", "worker_instance_id"))
481
+ or (value["lease_id"] is not None and (not isinstance(value["lease_id"], str)
482
+ or str(uuid.UUID(value["lease_id"])) != value["lease_id"]))):
483
+ raise ValueError("native lifecycle intent is invalid")
484
+ started = value["started"]
485
+ if started is not None and (not isinstance(started, dict) or set(started) != {"pid", "instance_id"}
486
+ or type(started["pid"]) is not int or started["pid"] <= 0
487
+ or not isinstance(started["instance_id"], str)
488
+ or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", started["instance_id"]) is None):
489
+ raise ValueError("native lifecycle startup identity is invalid")
490
+ if (value["phase"] == "releasing" and started is None
491
+ or value["phase"] not in {"starting", "releasing"} and started is not None
492
+ or value["phase"] != "prepared" and value["lease_id"] is None):
493
+ raise ValueError("native lifecycle phase lacks its admission evidence")
494
+ paths = {"root": layout.install_root, "worker": layout.worker_release,
495
+ "gateway": layout.gateway_release, "config": layout.config_root, "state": layout.state_root}
496
+ if not isinstance(value["identities"], dict) or set(value["identities"]) != set(paths):
497
+ raise ValueError("native lifecycle directory identities are invalid")
498
+ for name, path in paths.items():
499
+ _check_binding(path, value["identities"][name])
500
+ manifest, _mode = files._read_file(layout.manifest_path, private=True)
501
+ if hashlib.sha256(manifest).hexdigest() != value["layout_sha256"]:
502
+ raise RuntimeError("installed layout changed after lifecycle admission")
503
+ return value
504
+
505
+
506
+ def pending_lifecycle_operation(root: Path, runtime_root: Path) -> str:
507
+ layout = files.installed_layout(root)
508
+ intent = read_lifecycle_intent(layout)
509
+ if intent is None or files._path(runtime_root) != layout.worker_release:
510
+ raise RuntimeError("worker is outside the retained lifecycle operation")
511
+ marker = root / ".execution-uninstall.json"
512
+ if marker.exists() or marker.is_symlink():
513
+ from execution_uninstall import _read_intent
514
+ removal = _read_intent(root, layout)
515
+ if (intent["phase"] != "stopped" or removal is None
516
+ or removal["operation_id"] != intent["operation_id"]
517
+ or removal["server_identity"] != intent["server_identity"]):
518
+ raise RuntimeError("uninstall and lifecycle ownership conflict")
519
+ return intent["operation_id"]
520
+
521
+
522
+ def retire_lifecycle_intent(layout: files.ExecutionLayout, intent: dict) -> None:
523
+ if read_lifecycle_intent(layout) != intent:
524
+ raise RuntimeError("native lifecycle intent changed before retirement")
525
+ (layout.install_root / LIFECYCLE_NAME).unlink()
526
+ files._fsync_directory(layout.install_root)
527
+
528
+
529
+ class LifecycleController:
530
+ """Idle-only control of an installed pair, not an installer or force repair.
531
+
532
+ Failures retain an exact operation journal and any sealed admission hold.
533
+ Retry may finish that operation; an unjournaled broken/stopped installation
534
+ or a changed worker epoch is never guessed safe. Explicit start releases
535
+ startup admission only after both components have authenticated health.
536
+ """
537
+
538
+ def __init__(self, layout: files.ExecutionLayout, *, services: NativeServices | None = None,
539
+ control: WorkerControl | None = None, health_timeout: float = 30.0):
540
+ self.layout = layout
541
+ self.services = services or NativeServices(layout)
542
+ self.control = control or WorkerControl(services=self.services)
543
+ self.health_timeout = health_timeout
544
+
545
+ def _save(self, intent: dict, **changes) -> dict:
546
+ if read_lifecycle_intent(self.layout) != intent:
547
+ raise RuntimeError("native lifecycle intent changed")
548
+ updated = {**intent, **changes}
549
+ files._atomic_write(self.layout.install_root / LIFECYCLE_NAME, files._json_bytes(updated))
550
+ return updated
551
+
552
+ def _unchanged(self, originals: dict) -> None:
553
+ from execution_uninstall import _service_files, _no_pending_update
554
+ from update_handoff import _no_activation
555
+ _no_activation(self.layout.install_root)
556
+ marker = self.layout.install_root / ".execution-uninstall.json"
557
+ if marker.exists() or marker.is_symlink():
558
+ raise RuntimeError("uninstall owns this native lifecycle")
559
+ _no_pending_update(self.layout)
560
+ if _service_files(self.layout) != originals:
561
+ raise RuntimeError("native service bindings changed during lifecycle operation")
562
+ self.services.validate_registered_bindings()
563
+
564
+ def _new_intent(self, prior: dict) -> dict:
565
+ from execution_preparation import _binding
566
+ receipt = self.control.receipt(self.layout, self.services)
567
+ record, status = self.control.status(self.layout)
568
+ if type(status.get("native_lifecycle_protocol")) is not int or status["native_lifecycle_protocol"] != 1:
569
+ raise RuntimeError("installed worker does not support safe paired lifecycle commands; update the installed server through its signed managed update first")
570
+ if (receipt.get("worker_pid") != prior["worker"].get("pid")
571
+ or receipt.get("gateway_pid") != prior["gateway"].get("pid")
572
+ or receipt.get("worker_release") != str(self.layout.worker_release)
573
+ or record.get("pid") != receipt.get("worker_pid")
574
+ or record.get("instance_id") != receipt.get("worker_instance_id")
575
+ or status.get("idle") is not True or status.get("lease") is not None):
576
+ raise RuntimeError("execution is busy, changed, or another operation owns admission; no services stopped")
577
+ manifest, _mode = files._read_file(self.layout.manifest_path, private=True)
578
+ intent = {"format": 1, "operation_id": str(uuid.uuid4()), "phase": "prepared", "lease_id": None,
579
+ "started": None, "server_identity": receipt.get("server_identity"),
580
+ "worker_pid": receipt["worker_pid"], "gateway_pid": receipt["gateway_pid"],
581
+ "worker_instance_id": receipt["worker_instance_id"],
582
+ "layout_sha256": hashlib.sha256(manifest).hexdigest(),
583
+ "identities": {name: _binding(path) for name, path in {
584
+ "root": self.layout.install_root, "worker": self.layout.worker_release,
585
+ "gateway": self.layout.gateway_release, "config": self.layout.config_root,
586
+ "state": self.layout.state_root}.items()}}
587
+ if not isinstance(intent["server_identity"], str) or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", intent["server_identity"]) is None:
588
+ raise RuntimeError("authenticated server identity is unavailable")
589
+ if self.services.snapshot() != prior or read_lifecycle_intent(self.layout) is not None:
590
+ raise RuntimeError("native ownership changed before lifecycle admission")
591
+ files._atomic_write(self.layout.install_root / LIFECYCLE_NAME, files._json_bytes(intent))
592
+ return intent
593
+
594
+ def _stop(self, intent: dict, originals: dict) -> dict:
595
+ from execution_uninstall import _owned_lease, _no_provider_children
596
+ if intent["phase"] in {"starting", "releasing"}:
597
+ raise RuntimeError("an owned start must finish before another stop")
598
+ self._unchanged(originals)
599
+ prior = self.services.snapshot()
600
+ if any(prior[role].get("state") not in {"running", "stopped", "absent"} for role in ("worker", "gateway")):
601
+ raise RuntimeError("native lifecycle state is uncertain")
602
+ if prior["worker"].get("state") == "running":
603
+ record, status = self.control.status(self.layout)
604
+ if (record.get("pid") != intent["worker_pid"] or prior["worker"].get("pid") != intent["worker_pid"]
605
+ or record.get("instance_id") != intent["worker_instance_id"]
606
+ or record.get("release_root") != str(self.layout.worker_release)):
607
+ raise RuntimeError("worker epoch changed after lifecycle admission")
608
+ if intent["lease_id"] is not None and (status.get("lease") or {}).get("lease_id") != intent["lease_id"]:
609
+ raise RuntimeError("owned lifecycle admission changed")
610
+ try:
611
+ sealed = self.control.seal_for_stop(self.layout, intent["operation_id"], intent["worker_pid"])
612
+ except (OSError, ValueError, RuntimeError) as error:
613
+ raise RuntimeError("lifecycle admission was not confirmed; its owned retry evidence was retained and no service was stopped by this attempt; retry the same command when work is idle (a changed worker requires ownership recovery)") from error
614
+ if sealed["record"] != record:
615
+ raise RuntimeError("worker changed while lifecycle admission was sealed")
616
+ intent = self._save(intent, phase="sealed", lease_id=sealed["lease"]["lease_id"])
617
+ self._unchanged(originals)
618
+ if self.services.snapshot() != prior:
619
+ raise RuntimeError("native jobs changed after lifecycle admission")
620
+ for role in ("gateway", "worker"):
621
+ current = self.services.snapshot()[role]
622
+ if current.get("state") in {"stopped", "absent"}:
623
+ self.services.set_enabled(role, False)
624
+ continue
625
+ if current.get("state") != "running" or current.get("pid") != intent[role + "_pid"]:
626
+ raise RuntimeError("native process changed before its authorized stop")
627
+ if role == "worker":
628
+ now_record, now_status = self.control.status(self.layout)
629
+ if (now_record != sealed["record"] or now_status.get("lease") != sealed["lease"]
630
+ or now_status.get("idle") is not True):
631
+ raise RuntimeError("worker admission changed before its authorized stop")
632
+ self._unchanged(originals)
633
+ self.services.set_enabled(role, False)
634
+ self.services.stop(role)
635
+ elif intent["lease_id"] is None or prior["gateway"].get("state") == "running":
636
+ raise RuntimeError("stopped worker lacks an owned completed shutdown")
637
+ else:
638
+ for role in ("gateway", "worker"):
639
+ self.services.set_enabled(role, False)
640
+ if any(self.services.snapshot()[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway")):
641
+ raise RuntimeError("both native services must stop before lifecycle completion")
642
+ with _owned_lease(self.layout.state_root / "admin/state-owner.lock"):
643
+ _no_provider_children(self.layout.state_root)
644
+ self._unchanged(originals)
645
+ return self._save(intent, phase="stopped")
646
+
647
+ def _wait_worker(self, intent: dict) -> dict:
648
+ deadline = time.monotonic() + self.health_timeout
649
+ while True:
650
+ try:
651
+ record, status = self.control.status(self.layout)
652
+ native = self.services.snapshot()["worker"]
653
+ if (native.get("state") != "running" or native.get("pid") != record.get("pid")
654
+ or record.get("release_root") != str(self.layout.worker_release)):
655
+ raise RuntimeError("native worker does not own startup admission")
656
+ started = {"pid": record["pid"], "instance_id": record["instance_id"]}
657
+ if intent["started"] is not None and started != intent["started"]:
658
+ raise RuntimeError("worker epoch changed during lifecycle startup")
659
+ if intent["phase"] != "releasing" or status.get("lease") is not None:
660
+ self.control.require_startup_hold(self.layout, intent["operation_id"])
661
+ return started
662
+ except (OSError, ValueError, RuntimeError) as error:
663
+ if time.monotonic() >= deadline:
664
+ raise RuntimeError("owned native worker did not reach held startup; retry start after recovery") from error
665
+ time.sleep(0.1)
666
+
667
+ def _start(self, intent: dict, originals: dict) -> dict:
668
+ from execution_uninstall import _owned_lease, _no_provider_children
669
+ if intent["phase"] == "stopped":
670
+ with _owned_lease(self.layout.state_root / "admin/state-owner.lock"):
671
+ _no_provider_children(self.layout.state_root)
672
+ self._unchanged(originals)
673
+ if any(self.services.snapshot()[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway")):
674
+ raise RuntimeError("stopped native jobs changed before authorized start")
675
+ intent = self._save(intent, phase="starting")
676
+ if intent["phase"] not in {"starting", "releasing"}:
677
+ raise RuntimeError("native shutdown must complete before starting")
678
+ self._unchanged(originals)
679
+ native = self.services.snapshot()
680
+ if native["worker"].get("state") in {"stopped", "absent"}:
681
+ if intent["started"] is not None or native["gateway"].get("state") == "running":
682
+ raise RuntimeError("owned startup process disappeared; automatic replacement is refused")
683
+ self.services.start("worker")
684
+ started = self._wait_worker(intent)
685
+ if intent["started"] is None:
686
+ intent = self._save(intent, started=started)
687
+ self._unchanged(originals)
688
+ gateway = self.services.snapshot()["gateway"]
689
+ if gateway.get("state") in {"stopped", "absent"}:
690
+ self.services.start("gateway")
691
+ receipt = ActivationController(self.layout, services=self.services, control=self.control,
692
+ health_timeout=self.health_timeout)._wait_receipt(self.layout)
693
+ if (receipt.get("server_identity") != intent["server_identity"]
694
+ or receipt.get("worker_pid") != intent["started"]["pid"]
695
+ or receipt.get("worker_instance_id") != intent["started"]["instance_id"]):
696
+ raise RuntimeError("authenticated lifecycle identity changed")
697
+ self._wait_worker(intent)
698
+ self._unchanged(originals)
699
+ intent = self._save(intent, phase="releasing")
700
+ self.control.release(self.layout, intent["operation_id"])
701
+ retire_lifecycle_intent(self.layout, intent)
702
+ return {"ok": True, "status": "running", "receipt": receipt}
703
+
704
+ def run(self, action: str) -> dict:
705
+ if action not in {"start", "stop", "restart"}:
706
+ raise ValueError("unknown native lifecycle action")
707
+ from execution_uninstall import _service_files, _hold_preparation_leases
708
+ from execution_recovery import require_retired_owners
709
+ with InstallationLock(self.layout.install_root), ExitStack() as leases:
710
+ if files.installed_layout(self.layout.install_root) != self.layout:
711
+ raise RuntimeError("native lifecycle layout changed")
712
+ originals = _service_files(self.layout)
713
+ self._unchanged(originals)
714
+ require_retired_owners(self.layout.install_root)
715
+ _hold_preparation_leases(self.layout.install_root, leases)
716
+ intent = read_lifecycle_intent(self.layout)
717
+ if intent is None:
718
+ prior = self.services.snapshot()
719
+ if any(prior[role].get("state") != "running" for role in ("worker", "gateway")):
720
+ raise RuntimeError("unjournaled stopped or broken services require ownership recovery")
721
+ if action == "start":
722
+ return {"ok": True, "status": "running", "receipt": self.control.receipt(self.layout, self.services)}
723
+ intent = self._new_intent(prior)
724
+ if intent["phase"] not in {"starting", "releasing"}:
725
+ intent = self._stop(intent, originals)
726
+ if action == "stop":
727
+ if intent["phase"] != "stopped":
728
+ raise RuntimeError("retry start to finish the pending owned startup")
729
+ return {"ok": True, "status": "stopped"}
730
+ return self._start(intent, originals)
731
+
732
+
429
733
  class ActivationController:
430
734
  def __init__(self, layout: files.ExecutionLayout, *, services: NativeServices | None = None,
431
735
  control: WorkerControl | None = None, health_timeout: float = 30.0) -> None:
@@ -107,7 +107,7 @@ def _source_inventory(source: Path) -> dict[str, str]:
107
107
 
108
108
 
109
109
  def _conflicts(root: Path) -> None:
110
- for name in (".execution-transaction", ".execution-uninstall.json"):
110
+ for name in (".execution-transaction", ".execution-uninstall.json", ".execution-lifecycle.json"):
111
111
  if (root / name).exists() or (root / name).is_symlink():
112
112
  raise RuntimeError("another lifecycle operation owns this installation")
113
113
 
@@ -205,6 +205,7 @@ def uninstall(root: Path, *, config_root: Path, state_root: Path, home: Path,
205
205
  native = services or NativeServices(layout)
206
206
  control = control or WorkerControl()
207
207
  originals = _service_files(layout)
208
+ native.validate_registered_bindings()
208
209
  _no_pending_update(layout)
209
210
  _hold_preparation_leases(root, leases)
210
211
  _removable_tree(root)
@@ -216,6 +217,24 @@ def uninstall(root: Path, *, config_root: Path, state_root: Path, home: Path,
216
217
  if intent is not None and intent["purge_state"] != purge_state:
217
218
  raise RuntimeError("retry must preserve the existing uninstall data-removal choice")
218
219
  prior = native.snapshot()
220
+ from execution_manage import read_lifecycle_intent, retire_lifecycle_intent
221
+ lifecycle = read_lifecycle_intent(layout)
222
+ if lifecycle is not None:
223
+ # Adopt only a proven completed owned stop. Never start a worker
224
+ # merely to remove it: that could run saved scheduled/queued work.
225
+ if (lifecycle["phase"] != "stopped"
226
+ or any(prior[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway"))):
227
+ raise RuntimeError("finish the owned lifecycle stop before uninstalling")
228
+ native.validate_registered_bindings()
229
+ with _owned_lease(state_root / "admin/state-owner.lock"):
230
+ _no_provider_children(state_root)
231
+ if intent is None:
232
+ intent = _write_intent(root, layout, lifecycle["operation_id"], purge_state,
233
+ lifecycle["server_identity"])
234
+ elif (intent["operation_id"] != lifecycle["operation_id"]
235
+ or intent["server_identity"] != lifecycle["server_identity"]):
236
+ raise RuntimeError("uninstall and lifecycle journal ownership conflict")
237
+ retire_lifecycle_intent(layout, lifecycle)
219
238
  # An interrupted graceful stop can precede the stopped-phase write.
220
239
  # The exact intent, empty child registry and exclusive state lease
221
240
  # below supply the missing proof; an unjournaled stopped install fails.
@@ -6,7 +6,6 @@ activation remains incomplete even after it has published the new files.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
- import json
10
9
  from pathlib import Path
11
10
  from typing import Any
12
11
 
@@ -22,17 +21,11 @@ def current_components(
22
21
  """Read-only proof, invoked off the server event loop for an equal-version ensure."""
23
22
  root = files._path(root)
24
23
  files._owned_directory(root)
25
- for name in (".activation-transaction", ".execution-transaction"):
24
+ for name in (".activation-transaction", ".execution-transaction", ".execution-lifecycle.json", ".execution-uninstall.json"):
26
25
  journal = root / name
27
26
  if journal.exists() or journal.is_symlink():
28
27
  return False
29
- data, _mode = files._read_file(root / files.LAYOUT_NAME, private=True)
30
- document = json.loads(data)
31
- if not isinstance(document, dict) or document.get("format") != files.FORMAT:
32
- raise ValueError("installed execution layout is invalid")
33
- layout = files.ExecutionLayout.from_dict(document.get("layout"))
34
- if layout.install_root != root or document != files.layout_manifest(layout):
35
- raise ValueError("installed execution layout does not match its runtime")
28
+ layout = files.installed_layout(root)
36
29
  receipt: dict[str, Any] = WorkerControl().receipt(layout, NativeServices(layout))
37
30
  if (receipt.get("server_identity") != expected_server_identity
38
31
  or receipt.get("worker_instance_id") != expected_worker_instance):
@@ -45,5 +38,5 @@ def current_components(
45
38
  # Do not claim completion over the operation that just acquired ownership.
46
39
  return complete and not any(
47
40
  (root / name).exists() or (root / name).is_symlink()
48
- for name in (".activation-transaction", ".execution-transaction")
41
+ for name in (".activation-transaction", ".execution-transaction", ".execution-lifecycle.json", ".execution-uninstall.json")
49
42
  )
@@ -156,6 +156,11 @@ def opencode_error_message(error: Any) -> str:
156
156
  if not message:
157
157
  message = str(error.get("message") or "")
158
158
  name = str(error.get("name") or "")
159
+ status = data.get("statusCode") if isinstance(data, dict) else None
160
+ if type(status) is int and 100 <= status <= 599:
161
+ # Preserve useful HTTP diagnostics without exposing request headers,
162
+ # gateway response bodies or URLs that may contain credentials.
163
+ name = f"{name} (HTTP {status})" if name else f"HTTP {status}"
159
164
  if message and name:
160
165
  return f"{name}: {message}"
161
166
  return message or name
@@ -22,9 +22,12 @@ import httpx
22
22
  from fastapi import APIRouter, HTTPException, Request
23
23
  from fastapi.responses import JSONResponse
24
24
 
25
- from codex_provider import validate_selection, validate_model, endpoint_model_catalog
25
+ from codex_provider import validate_selection, validate_model, endpoint_model_catalog, endpoint_api_base_url
26
26
 
27
27
  MAX_BODY_BYTES = 16 * 1024
28
+ # Reasoning can consume output tokens before the short visible check answer.
29
+ # Keep this explicit probe bounded and single-shot, including for such models.
30
+ MODEL_CHECK_MAX_OUTPUT_TOKENS = 256
28
31
  PROTOCOLS = {"claude": {"anthropic"}, "opencode": {"anthropic", "chat_completions", "responses"}, "cursor": {"cursor"}}
29
32
  RESULTS = {"verified", "authentication_failed", "rate_limited", "unsupported", "connection_failed", "invalid_response", "model_required", "cli_update_required"}
30
33
 
@@ -277,7 +280,7 @@ class ConnectionStore:
277
280
  model = require_model(value)
278
281
  config = json.loads(env.get("OPENCODE_CONFIG_CONTENT") or "{}")
279
282
  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}"}
283
+ options = {"baseURL": endpoint_api_base_url(value["base_url"], value["protocol"]), "apiKey": "{env:AGENTSDOCK_CUSTOM_API_KEY}"}
281
284
  if value["protocol"] == "anthropic" and value["auth_header"] == "bearer":
282
285
  options["headers"] = {"Authorization": "Bearer {env:AGENTSDOCK_CUSTOM_API_KEY}", "x-api-key": ""}
283
286
  config.setdefault("provider", {})["agentsdock_custom"] = {"npm": adapter, "name": "Custom endpoint", "options": options, "models": {model: {"name": model}}}
@@ -361,8 +364,7 @@ async def probe_credentials(selected: dict) -> str:
361
364
  headers["Authorization"] = "Bearer " + selected["api_key"]
362
365
  url = urlsplit(base)
363
366
  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")
367
+ target = "https://openrouter.ai/api/v1/key" if router else endpoint_api_base_url(base, protocol) + "/models"
366
368
  async def send():
367
369
  async with httpx.AsyncClient(timeout=10, follow_redirects=False, trust_env=False) as client:
368
370
  async with client.stream("GET", target, headers=headers) as response:
@@ -403,18 +405,18 @@ async def probe(selected: dict) -> str:
403
405
  headers["x-api-key"] = selected["api_key"]
404
406
  else:
405
407
  headers["Authorization"] = "Bearer " + selected["api_key"]
406
- base = selected["base_url"]
408
+ base = endpoint_api_base_url(selected["base_url"], protocol)
407
409
  prompt = "Connection check. Reply with OK only."
408
410
  if protocol == "anthropic":
409
- suffix = "/messages" if base.endswith("/v1") else "/v1/messages"
411
+ suffix = "/messages"
410
412
  headers["anthropic-version"] = "2023-06-01"
411
- body = {"model": selected["model"], "max_tokens": 32, "messages": [{"role": "user", "content": prompt}]}
413
+ body = {"model": selected["model"], "max_tokens": MODEL_CHECK_MAX_OUTPUT_TOKENS, "messages": [{"role": "user", "content": prompt}]}
412
414
  elif protocol == "responses":
413
415
  suffix = "/responses"
414
- body = {"model": selected["model"], "max_output_tokens": 64, "input": prompt, "store": False}
416
+ body = {"model": selected["model"], "max_output_tokens": MODEL_CHECK_MAX_OUTPUT_TOKENS, "input": prompt, "store": False}
415
417
  else:
416
418
  suffix = "/chat/completions"
417
- body = {"model": selected["model"], "max_tokens": 32, "messages": [{"role": "user", "content": prompt}]}
419
+ body = {"model": selected["model"], "max_tokens": MODEL_CHECK_MAX_OUTPUT_TOKENS, "messages": [{"role": "user", "content": prompt}]}
418
420
  async def send():
419
421
  async with httpx.AsyncClient(timeout=15, follow_redirects=False, trust_env=False) as client:
420
422
  async with client.stream("POST", base + suffix, headers=headers, json=body) as response:
@@ -600,7 +602,11 @@ def create_router(*, authorize, store: ConnectionStore, check=probe, account=nat
600
602
  current = store.read(backend)
601
603
  if current["revision"] != revision(payload["expected_revision"]):
602
604
  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.")
605
+ if not current.get("configured"):
606
+ raise HTTPException(409, "Connect a custom API first.")
607
+ # A saved, unverified connection can choose its first model.
608
+ # This write does not verify it or make it usable by chats;
609
+ # the explicit /check operation must still succeed.
604
610
  value = {key: current[key] for key in ("base_url", "api_key", "protocol", "auth_header")}
605
611
  saved = store.write(backend, current["revision"], {**value, "model": model, "expected_revision": current["revision"]}, current["last_result"], checked_at=current["checked_at"])
606
612
  cached = store.catalog_cache.get((backend, current["revision"]))
@@ -651,7 +657,11 @@ def create_router(*, authorize, store: ConnectionStore, check=probe, account=nat
651
657
  result = "cli_update_required" if discovered.get("cli_update_required") else "verified" if discovered["discovery_status"] == "ready" and discovered["models"] else "connection_failed"
652
658
  else:
653
659
  result = await check(selected)
654
- if request.method == "PUT" and result != "verified":
660
+ # Public catalogs cannot authenticate a key. Keep a private,
661
+ # explicitly unverified setup so the client can list models
662
+ # before choosing one for a subsequent explicit check.
663
+ pending_model = result == "model_required" and not selected.get("model") and backend != "cursor"
664
+ if request.method == "PUT" and result != "verified" and not pending_model:
655
665
  return reply({"ok": False, "status": result})
656
666
  configuration = await asyncio.to_thread(store.write, backend, expected, selected, result)
657
667
  if backend == "cursor" and result == "verified":