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