@agentsdock/server 1.0.7 → 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.
@@ -354,6 +354,12 @@ class CodexAppServerTurn:
354
354
  # Native turn identities are retained only for this owner's lifetime.
355
355
  # Delayed duplicate starts must not resurrect one of its finished turns.
356
356
  _completed_turn_ids: set[str] = field(default_factory=set)
357
+ # Unlike turn_id, this identity does not follow native goal/child turns.
358
+ # History repair needs the turn that accepted the original user input.
359
+ initial_turn_id: str = field(init=False, default="")
360
+
361
+ def __post_init__(self) -> None:
362
+ self.initial_turn_id = self.turn_id
357
363
 
358
364
  async def next_notification(self, timeout: float | None = None) -> dict[str, Any]:
359
365
  return await self._subscription.next_notification(timeout)
@@ -498,6 +504,8 @@ class CodexAppServerTurn:
498
504
  f"provisional turn already bound to {self.turn_id}, not {resolved}"
499
505
  )
500
506
  self.turn_id = resolved
507
+ if not self.initial_turn_id:
508
+ self.initial_turn_id = resolved
501
509
  if not self._retain_thread_stream:
502
510
  self._subscription.turn_id = resolved
503
511
 
@@ -1451,6 +1459,8 @@ class CodexAppServerClient:
1451
1459
  # keeping this id current is required for both event routing and
1452
1460
  # turn/interrupt to target the work that is actually running.
1453
1461
  if not active_turn.turn_id or method == "turn/started":
1462
+ if not active_turn.initial_turn_id:
1463
+ active_turn.initial_turn_id = turn_id
1454
1464
  active_turn.turn_id = turn_id
1455
1465
  if method == "turn/started":
1456
1466
  active_turn._completed = False
@@ -2674,6 +2684,7 @@ class CodexAppServerClient:
2674
2684
  # continue before its delayed start response is read.
2675
2685
  # Acceptance of that exact completed ID is proven; leave
2676
2686
  # the handle bound to the newer live turn, not the reply.
2687
+ provisional.initial_turn_id = turn_id
2677
2688
  return provisional
2678
2689
  raise CodexAppServerProtocolError(
2679
2690
  "turn/start response did not match its early notifications",
@@ -2681,6 +2692,7 @@ class CodexAppServerClient:
2681
2692
  safe_to_retry=False,
2682
2693
  )
2683
2694
  provisional.turn_id = turn_id
2695
+ provisional.initial_turn_id = turn_id
2684
2696
  if not retain_thread_stream:
2685
2697
  provisional._subscription.turn_id = turn_id
2686
2698
  return provisional
@@ -157,7 +157,7 @@ def _native_stamp(path: Path) -> tuple[int, int, int, int]:
157
157
 
158
158
 
159
159
  def _native_records(path: Path, expected: tuple[int, int, int, int], budget: _NativeReadBudget,
160
- *, end: int | None = None):
160
+ *, end: int | None = None, provider_source: bool = False):
161
161
  """Stream one fixed prefix; tool volume does not consume retained-key budget.
162
162
 
163
163
  A source proof needs only the prefix ending at its newest checkpoint. Never
@@ -181,7 +181,14 @@ def _native_records(path: Path, expected: tuple[int, int, int, int], budget: _Na
181
181
  if not line or len(line) > NATIVE_PROOF_LINE_BYTES or not line.endswith(b"\n"):
182
182
  raise _Unproven()
183
183
  offset += len(line)
184
- record = json.loads(line)
184
+ try:
185
+ record = json.loads(line)
186
+ except (ValueError, UnicodeDecodeError):
187
+ if not provider_source:
188
+ raise
189
+ # The provider importer skips malformed records. Their exact
190
+ # bytes still participate in the checkpoint digest below.
191
+ record = {}
185
192
  if not isinstance(record, dict):
186
193
  raise _Unproven()
187
194
  yield record, offset, line
@@ -229,6 +236,20 @@ def _public_assistant_item_id(event: dict) -> str | None:
229
236
  return item_id if public and isinstance(item_id, str) and 0 < len(item_id) <= 256 else None
230
237
 
231
238
 
239
+ def _native_owned_turn_ids(event: dict) -> set[str]:
240
+ """One logical run can retain its initial input across native continuations."""
241
+ return {value for field in ("provider_turn_id", "provider_initial_turn_id")
242
+ if isinstance(value := event.get(field), str) and 0 < len(value) <= 256}
243
+
244
+
245
+ def _native_assistant_item_owners(assistant_items: dict, completed_runs: set[str]) -> dict:
246
+ owners: dict = {}
247
+ for run, item_id, body_key in assistant_items:
248
+ if run in completed_runs:
249
+ owners.setdefault((item_id, body_key), set()).add(run)
250
+ return owners
251
+
252
+
232
253
  @dataclass
233
254
  class _SourceBudget:
234
255
  bytes_remaining: int
@@ -783,11 +804,12 @@ def _prove_native_replays(session_id: str, provider_id: str, events: Path, sourc
783
804
  candidates.append(target)
784
805
  elif event.get("imported") is not True and event.get("backend") in (None, "codex"):
785
806
  if event.get("type") == "turn_finished" and event.get("backend") == "codex" and event.get("transport") == "app-server":
786
- thread, turn = event.get("provider_thread_id"), event.get("provider_turn_id")
787
- if isinstance(thread, str) and _PROVIDER_ID.fullmatch(thread) and isinstance(turn, str) and 0 < len(turn) <= 256:
788
- owned = owners.setdefault((thread, turn), set())
789
- retained += run not in owned
790
- owned.add(run)
807
+ thread = event.get("provider_thread_id")
808
+ if isinstance(thread, str) and _PROVIDER_ID.fullmatch(thread):
809
+ for turn in _native_owned_turn_ids(event):
810
+ owned = owners.setdefault((thread, turn), set())
811
+ retained += run not in owned
812
+ owned.add(run)
791
813
  kind = "user" if event.get("type") == "turn_started" else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
792
814
  body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
793
815
  if kind and isinstance(body, str) and body and len(body) <= 4 * 1024 * 1024:
@@ -868,10 +890,19 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
868
890
  compactions, candidate_assistants = {}, set()
869
891
  compaction_tracker = CodexCompactionSummaryTracker(thread)
870
892
  delivery_bodies, delivery_source_ids = {}, {}
893
+ completed_runs = {run for (owner_thread, _turn), runs in owners.items()
894
+ if owner_thread == thread for run in runs}
895
+ assistant_item_owners = _native_assistant_item_owners({
896
+ (run, item_id, body_key): event
897
+ for (run, kind, body_key), events in native.items() if kind == "assistant"
898
+ for event in events if (item_id := _public_assistant_item_id(event)) is not None
899
+ }, completed_runs)
900
+ assistant_owner_evidence: dict = {}
901
+ assistant_source_turns: dict = {}
871
902
  allowed_header_owners, header_parents = {thread}, {}
872
903
  context_turn = None
873
904
  retained = 0
874
- for record, offset, line in _native_records(source, stamp, budget, end=max(wanted)):
905
+ for record, offset, line in _native_records(source, stamp, budget, end=max(wanted), provider_source=True):
875
906
  payload = record.get("payload")
876
907
  if offset == len(line) or record.get("type") == "session_meta":
877
908
  if record.get("type") != "session_meta" or not isinstance(payload, dict):
@@ -922,6 +953,14 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
922
953
  or not isinstance(timestamp, str) or not 0 < len(timestamp) <= 64):
923
954
  continue
924
955
  key = (turn, item["kind"], _text_key(item["text"]), item.get("source_text_sha256"))
956
+ if origin and item["kind"] == "assistant" and item.get("source_text_sha256") is None:
957
+ item_key = (origin["event_id"], _text_key(_native_assistant_text(item["text"])))
958
+ if item_key in assistant_item_owners:
959
+ assistant_source_turns.setdefault(item_key, set()).add(turn)
960
+ assistant_owner_evidence.setdefault(turn, []).append((offset, item_key))
961
+ retained += 1
962
+ if retained > MAX_KEYS:
963
+ raise _Unproven()
925
964
  if origin and item["kind"] == "user" and any(run in deliveries.starts for run in owners.get((thread, turn), ())):
926
965
  ids = delivery_source_ids.setdefault(turn, set())
927
966
  if len(ids) < 2:
@@ -968,7 +1007,14 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
968
1007
  if len(matches) != 1:
969
1008
  continue
970
1009
  key = next(iter(matches))
971
- source_ids, owned_runs = canonical.get(key, {}), owners.get((thread, key[0]), set())
1010
+ source_ids = canonical.get(key, {})
1011
+ owned_runs = set(owners.get((thread, key[0]), set()))
1012
+ # Older ledgers retained only the final continuation's turn ID. An
1013
+ # exact public assistant item proves the earlier turn belongs to the
1014
+ # same completed native run, without classifying any user wording.
1015
+ for offset, item_key in assistant_owner_evidence.get(key[0], ()):
1016
+ if offset <= end and len(assistant_source_turns[item_key]) == 1:
1017
+ owned_runs.update(assistant_item_owners[item_key])
972
1018
  # Compaction owns a particular source item and timestamp. Another
973
1019
  # genuine answer with the same body in this turn is a distinct item.
974
1020
  timestamp_origins = [origin for origin in source_ids.values() if origin["timestamp"] == timestamp]
@@ -1177,11 +1223,11 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1177
1223
  or not 0 < len(run) <= 256 or run.startswith("import_")):
1178
1224
  continue
1179
1225
  if (event.get("type") == "turn_finished" and event.get("backend") == "codex"
1180
- and event.get("transport") == "app-server" and event.get("provider_thread_id") == provider_id
1181
- and isinstance(event.get("provider_turn_id"), str) and 0 < len(event["provider_turn_id"]) <= 256):
1182
- owned = owners.setdefault(event["provider_turn_id"], set())
1183
- owner_count += run not in owned
1184
- owned.add(run)
1226
+ and event.get("transport") == "app-server" and event.get("provider_thread_id") == provider_id):
1227
+ for turn_id in _native_owned_turn_ids(event):
1228
+ owned = owners.setdefault(turn_id, set())
1229
+ owner_count += run not in owned
1230
+ owned.add(run)
1185
1231
  kind = "user" if event.get("type") == "turn_started" else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
1186
1232
  body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
1187
1233
  wake_hash = _native_mailbox_wake_hash(event) if kind == "user" else None
@@ -1198,6 +1244,25 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1198
1244
  assistant_items[(run, item_id, key[1])] = event["id"]
1199
1245
  if native_count + owner_count + len(assistant_items) + deliveries.count > MAX_KEYS:
1200
1246
  raise _Unproven()
1247
+ assistant_item_owners = _native_assistant_item_owners(
1248
+ assistant_items, {run for runs in owners.values() for run in runs},
1249
+ )
1250
+ assistant_source_turns: dict = {}
1251
+ for item in items:
1252
+ budget.check()
1253
+ origin = item.get("provider_origin")
1254
+ if (item.get("kind") != "assistant" or not isinstance(item.get("text"), str)
1255
+ or item.get("source_text_sha256") is not None or not isinstance(origin, dict)
1256
+ or origin.get("provider") != "codex" or origin.get("kind") != "assistant"
1257
+ or origin.get("session_id", provider_id) != provider_id
1258
+ or not isinstance(origin.get("turn_id"), str) or not 0 < len(origin["turn_id"]) <= 256):
1259
+ continue
1260
+ item_key = (origin.get("event_id"), _text_key(_native_assistant_text(item["text"])))
1261
+ if item_key in assistant_item_owners:
1262
+ assistant_source_turns.setdefault(item_key, set()).add(origin["turn_id"])
1263
+ for item_key, turns in assistant_source_turns.items():
1264
+ if len(turns) == 1:
1265
+ owners.setdefault(next(iter(turns)), set()).update(assistant_item_owners[item_key])
1201
1266
  delivery_proofs = _prove_pending_async_deliveries(
1202
1267
  session_id, provider_id, items, source_path, root, sync_checkpoint, parse_item,
1203
1268
  previous_seq, owners, deliveries, budget,
@@ -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
@@ -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: