@agentsdock/server 1.0.7-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. package/server/workspace_git.py +545 -0
@@ -0,0 +1,1120 @@
1
+ """Explicit custom Responses endpoints for the native Codex runtime."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from contextlib import suppress
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import stat
12
+ import tempfile
13
+ import threading
14
+ import uuid
15
+ from urllib.parse import urlsplit, urlunsplit
16
+ from urllib.error import HTTPError, URLError
17
+ from urllib.request import HTTPRedirectHandler, ProxyHandler, Request as URLRequest, build_opener
18
+
19
+ from fastapi import APIRouter, HTTPException, Request
20
+ from fastapi.responses import JSONResponse
21
+
22
+ from codex_auth import validate_api_key
23
+ from codex_app_server import CodexAppServerManager, decline_server_request
24
+ from codex_side_question import isolated_config, _verify_protocol
25
+ from side_questions import isolated_environment, run_isolated_command
26
+
27
+
28
+ PROVIDER_ID = "agentsdock_custom"
29
+ ENV_KEY = "AGENTSDOCK_CODEX_PROVIDER_API_KEY"
30
+ MAX_BODY_BYTES = 16 * 1024
31
+ MAX_SUMMARY_CAPABILITIES_BYTES = 192 * 1024
32
+ MAX_MODEL_CATALOG_BYTES = 384 * 1024
33
+ TEST_TIMEOUT_SECONDS = 45
34
+ TEST_TOOL = "agentsdock_compatibility_ping"
35
+ TEST_INSTRUCTIONS = (
36
+ "You are checking basic Codex compatibility. Follow only the current check prompt. "
37
+ "The only permitted tools are update_plan and agentsdock_compatibility_ping. "
38
+ "Do not access files, execute commands, browse, or use integrations."
39
+ )
40
+ TEST_PROMPT = (
41
+ "Perform this basic compatibility check only. First call update_plan with one "
42
+ "completed step named Compatibility check. Then call agentsdock_compatibility_ping "
43
+ "exactly once with an empty object. Reply with exactly the token returned by that "
44
+ "tool, without formatting. Do not use any other tools."
45
+ )
46
+ SUMMARY_TEST_PROMPT = (
47
+ "Without using any tools, compare 17 times 23 with 19 times 21. "
48
+ "Work out which product is larger, then answer in one short sentence."
49
+ )
50
+ EFFORT_OPTIONS = [{"value": value, "label": label} for value, label in (
51
+ ("none", "None"), ("minimal", "Minimal"), ("low", "Low"), ("medium", "Medium"),
52
+ ("high", "High"), ("xhigh", "Extra high"), ("max", "Max"), ("ultra", "Ultra"))]
53
+
54
+
55
+ def model_capability(value: dict | None = None) -> dict:
56
+ """Bound public capability metadata without inferring endpoint support."""
57
+ value = value or {}
58
+ supported = value.get("reasoning_supported")
59
+ supported = supported if isinstance(supported, bool) else None
60
+ efforts = value.get("reasoning_efforts")
61
+ allowed = {option["value"] for option in EFFORT_OPTIONS}
62
+ efforts = [effort.get("effort") if isinstance(effort, dict) else effort for effort in efforts] if isinstance(efforts, list) else []
63
+ efforts = list(dict.fromkeys(effort for effort in efforts if isinstance(effort, str) and effort in allowed)) if isinstance(efforts, list) else []
64
+ if supported is False:
65
+ efforts = []
66
+ summary_supported = value.get("reasoning_summary_supported")
67
+ return {"kind": "chat" if value.get("kind") == "chat" else "unknown",
68
+ "compatibility": value.get("compatibility") if value.get("compatibility") in {"verified", "unsupported"} else "unverified",
69
+ "reasoning_efforts": efforts, "reasoning_supported": True if efforts else supported,
70
+ "reasoning_summary_supported": summary_supported if isinstance(summary_supported, bool) else None}
71
+
72
+
73
+ def runtime_effort(selected: dict, catalog: dict, requested: object) -> str:
74
+ """Never inherit normal-account defaults or invent endpoint capabilities."""
75
+ capability = model_capability(catalog.get("model_capabilities", {}).get(selected.get("model")))
76
+ return requested if isinstance(requested, str) and requested in capability["reasoning_efforts"] else ""
77
+
78
+
79
+ def runtime_summary(selected: dict, catalog: dict) -> str:
80
+ """Request summaries only with separate, explicit endpoint/model evidence."""
81
+ capability = model_capability(catalog.get("model_capabilities", {}).get(selected.get("model")))
82
+ return "auto" if capability["reasoning_summary_supported"] is True else "none"
83
+
84
+
85
+ def turn_overrides(model: str, effort: str = "", *, summary: str = "none") -> dict:
86
+ # Null turn.effort means inherit. Replacing the native collaboration settings
87
+ # explicitly clears both a configured effort and an earlier turn's effort.
88
+ # Native default mode preserves the thread's base/developer instructions.
89
+ return {"model": model, "summary": "auto" if summary == "auto" else "none", "collaborationMode": {"mode": "default", "settings": {
90
+ "model": model, "reasoning_effort": effort or None, "developer_instructions": None}},
91
+ **({"effort": effort} if effort else {})}
92
+
93
+
94
+ def discovered_model_capability(entry: dict, model: str, native_models: dict | None = None) -> dict | None:
95
+ """Exclude affirmative non-chat evidence; unfamiliar IDs remain usable."""
96
+ non_chat = {"embedding", "embeddings", "rerank", "reranking", "moderation", "image-generation",
97
+ "text-to-image", "image", "text-to-speech", "speech-to-text", "transcription", "tts", "video-generation"}
98
+ chat = {"chat", "chat-completion", "chat-completions", "text-generation", "responses", "conversation", "llm"}
99
+ declared = {str(entry.get(key) or "").lower().replace("_", "-") for key in ("type", "task", "kind")}
100
+ if declared & non_chat and not declared & chat:
101
+ return None
102
+ name = model.lower().rsplit("/", 1)[-1]
103
+ if not declared & chat and re.match(r"^(?:text-embedding(?:-|$)|(?:omni-|text-)?moderation(?:-|$)|dall-e(?:-|$)|gpt-image(?:-|$)|whisper(?:-|$)|tts(?:-|$)|(?:text-)?rerank(?:-|$)|sora(?:-|$))", name):
104
+ return None
105
+ capabilities = entry.get("capabilities") if isinstance(entry.get("capabilities"), dict) else {}
106
+ architecture = entry.get("architecture") if isinstance(entry.get("architecture"), dict) else {}
107
+ outputs = entry.get("output_modalities", architecture.get("output_modalities"))
108
+ if not declared & chat and isinstance(outputs, list) and outputs and all(
109
+ isinstance(output, str) and output in {"image", "audio", "video", "embedding", "embeddings"}
110
+ for output in outputs
111
+ ):
112
+ return None
113
+ reasoning = entry.get("reasoning") if isinstance(entry.get("reasoning"), dict) else {}
114
+ supported = entry.get("reasoning_supported", entry.get("supports_reasoning", capabilities.get("reasoning", reasoning.get("supported"))))
115
+ effort_fields = [(source, key) for source, keys in (
116
+ (entry, ("supported_reasoning_efforts", "reasoning_efforts", "supported_reasoning_levels")),
117
+ (capabilities, ("supported_reasoning_efforts", "reasoning_efforts", "supported_reasoning_levels")),
118
+ (reasoning, ("efforts", "supported_reasoning_levels")),
119
+ ) for key in keys]
120
+ efforts = next((source[key] for source, key in effort_fields if key in source), [])
121
+ explicit_reasoning = (any(key in source for source, key in effort_fields)
122
+ or any(key in entry for key in ("reasoning_supported", "supports_reasoning"))
123
+ or "reasoning" in capabilities or "supported" in reasoning)
124
+ if not explicit_reasoning and entry.get("owned_by") == "openai" and entry.get("mode") == "responses":
125
+ # An explicitly OpenAI Responses model may use its installed native
126
+ # model's effort choices. This does not verify gateway compatibility.
127
+ native_entries = native_models.get("models") if isinstance(native_models, dict) else None
128
+ native_name = model.rsplit("/", 1)[-1]
129
+ native_model = next((item for item in native_entries
130
+ if isinstance(item, dict) and item.get("slug") == native_name), None) if isinstance(native_entries, list) else None
131
+ if native_model:
132
+ efforts = native_model.get("supported_reasoning_levels", [])
133
+ summary_supported = entry.get("reasoning_summary_supported", entry.get("supports_reasoning_summary_parameter",
134
+ capabilities.get("reasoning_summary", reasoning.get("summary_supported"))))
135
+ return model_capability({"kind": "chat" if declared & chat else "unknown",
136
+ "reasoning_supported": supported, "reasoning_efforts": efforts,
137
+ "reasoning_summary_supported": summary_supported})
138
+
139
+
140
+ def validate_model(value: object) -> str:
141
+ if not isinstance(value, str) or not 1 <= len(value.strip()) <= 256 or any(ord(char) < 33 or ord(char) > 126 for char in value.strip()):
142
+ raise HTTPException(400, "Enter a model ID without whitespace or control characters.")
143
+ return value.strip()
144
+
145
+
146
+ def validate_selection(value: object, *, require_key: bool = True) -> dict:
147
+ fields = {"base_url", "api_key"} if require_key else {"base_url"}
148
+ if not isinstance(value, dict) or not fields.issubset(value) or set(value) - fields - {"model"}:
149
+ raise HTTPException(400, "Provide the endpoint and a fresh provider API key.")
150
+ base, model = value.get("base_url"), value.get("model")
151
+ if not isinstance(base, str) or not 1 <= len(base.strip()) <= 2048:
152
+ raise HTTPException(400, "Enter a valid provider base URL.")
153
+ base = base.strip().rstrip("/")
154
+ if any(ord(char) < 33 or ord(char) > 126 for char in base) or "\\" in base:
155
+ raise HTTPException(400, "Enter a valid provider base URL.")
156
+ try:
157
+ url = urlsplit(base)
158
+ port = url.port
159
+ valid = bool(url.hostname) and url.scheme in {"https", "http"} and url.username is None and url.password is None and not url.query and not url.fragment
160
+ valid = valid and (url.scheme == "https" or url.hostname in {"localhost", "127.0.0.1", "::1"})
161
+ valid = valid and not url.path.lower().endswith(("/responses", "/chat/completions"))
162
+ if port is not None and not 1 <= port <= 65535:
163
+ valid = False
164
+ except ValueError:
165
+ valid = False
166
+ if not valid:
167
+ raise HTTPException(400, "Use an HTTPS base URL, or HTTP on loopback, without credentials, query, fragment or an API operation suffix.")
168
+ result = {"base_url": urlunsplit((url.scheme.lower(), url.netloc.lower(), url.path, "", ""))}
169
+ if model is not None:
170
+ result["model"] = validate_model(model)
171
+ if require_key:
172
+ result["api_key"] = validate_api_key({"api_key": value["api_key"]})
173
+ return result
174
+
175
+
176
+ def binding(selection: dict) -> str:
177
+ return hashlib.sha256(json.dumps([selection["base_url"]], separators=(",", ":")).encode()).hexdigest()
178
+
179
+
180
+ def legacy_binding(selection: dict) -> str | None:
181
+ model = selection.get("legacy_model") or selection.get("model")
182
+ return hashlib.sha256(json.dumps([selection["base_url"], model], separators=(",", ":")).encode()).hexdigest() if model else None
183
+
184
+
185
+ def catalog_key(selected: dict) -> str:
186
+ return hashlib.sha256((selected["base_url"] + "\0" + selected["api_key"]).encode()).hexdigest()
187
+
188
+
189
+ def session_choice(value) -> str:
190
+ choice = "default" if value is None else value
191
+ if not isinstance(choice, str) or choice not in {"default", "custom"}:
192
+ raise HTTPException(400, "Codex provider must be default or custom.")
193
+ return choice
194
+
195
+
196
+ class ProviderStore:
197
+ """Atomic metadata pointer plus private, endpoint-bound credential records."""
198
+ def __init__(self, root: Path):
199
+ self.root = root
200
+ self.lock = threading.RLock()
201
+ self._catalogs: dict[str, dict] = {}
202
+ self._saved_catalogs: dict[str, dict] = {}
203
+ self._model_capabilities: dict[str, dict[str, dict]] = {}
204
+ self._summary_capabilities: dict[str, dict[str, bool]] = {}
205
+ self._public_selections: dict[str, dict] = {}
206
+ self._revision_catalog_keys: dict[str, str] = {}
207
+
208
+ def _directory(self, *, create=False):
209
+ if self.root.is_symlink() or self.root.parent.is_symlink():
210
+ raise HTTPException(503, "Provider credential storage is unavailable.")
211
+ if create:
212
+ self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
213
+ if self.root.exists():
214
+ info = self.root.stat()
215
+ if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
216
+ raise HTTPException(503, "Provider credential storage must be private to the server user.")
217
+
218
+ def _read(self, name: str, *, max_bytes=MAX_BODY_BYTES) -> dict | None:
219
+ self._directory()
220
+ try:
221
+ fd = os.open(self.root / name, os.O_RDONLY | os.O_NOFOLLOW)
222
+ except FileNotFoundError:
223
+ return None
224
+ except OSError:
225
+ raise HTTPException(503, "Provider credential storage is unavailable.") from None
226
+ try:
227
+ info = os.fstat(fd)
228
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > max_bytes:
229
+ raise ValueError("unsafe credential file")
230
+ with os.fdopen(fd, "r", encoding="utf-8", closefd=False) as stream:
231
+ value = json.load(stream)
232
+ if not isinstance(value, dict):
233
+ raise ValueError("invalid record")
234
+ return value
235
+ finally:
236
+ os.close(fd)
237
+
238
+ def _atomic(self, name: str, value: dict):
239
+ self._directory(create=True)
240
+ target = self.root / name
241
+ if target.is_symlink():
242
+ raise HTTPException(503, "Provider credential storage is unavailable.")
243
+ fd, temporary = tempfile.mkstemp(prefix=".write-", dir=self.root)
244
+ try:
245
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
246
+ json.dump(value, stream, separators=(",", ":"))
247
+ stream.flush()
248
+ os.fsync(stream.fileno())
249
+ os.replace(temporary, target)
250
+ # replace is the commit point; durability failure must not make
251
+ # save delete the credential now selected by the metadata pointer.
252
+ durable = False
253
+ with suppress(OSError):
254
+ directory = os.open(self.root, os.O_RDONLY)
255
+ try:
256
+ os.fsync(directory)
257
+ durable = True
258
+ finally:
259
+ os.close(directory)
260
+ return durable
261
+ finally:
262
+ with suppress(FileNotFoundError):
263
+ os.unlink(temporary)
264
+
265
+ def selection(self, *, include_key=False, revision: str | None = None, include_revision=False) -> dict | None:
266
+ with self.lock:
267
+ try:
268
+ metadata = self._read("settings.json") or {}
269
+ identifier = revision or metadata.get("credential_id")
270
+ if not identifier and not metadata:
271
+ return None
272
+ if not isinstance(identifier, str) or not re.fullmatch(r"[0-9a-f]{32}", identifier):
273
+ raise ValueError("invalid credential identity")
274
+ credential = self._read("credential-" + identifier + ".json")
275
+ if not credential:
276
+ raise ValueError("missing credential")
277
+ source = metadata if identifier == metadata.get("credential_id") else credential
278
+ selected = validate_selection({name: source[name] for name in ("base_url", "model") if name in source}, require_key=False)
279
+ legacy_model = credential.get("legacy_model") or source.get("model")
280
+ if legacy_model:
281
+ selected["legacy_model"] = validate_model(legacy_model)
282
+ if not credential.get("binding") or credential.get("binding") not in (binding(selected), legacy_binding(selected)):
283
+ raise ValueError("credential binding mismatch")
284
+ key = validate_api_key({"api_key": credential.get("api_key")})
285
+ self._public_selections[identifier] = {**selected, "credential_id": identifier}
286
+ self._revision_catalog_keys[identifier] = catalog_key({**selected, "api_key": key})
287
+ if include_key:
288
+ selected["api_key"] = key
289
+ if include_revision or revision:
290
+ selected["credential_id"] = identifier
291
+ if not include_revision and revision is None:
292
+ selected.pop("legacy_model", None)
293
+ return selected
294
+ except Exception:
295
+ raise HTTPException(503, "Saved provider settings or credentials are unavailable.") from None
296
+
297
+ def status(self, *, available=True) -> dict:
298
+ selected = self.selection(include_revision=True)
299
+ return {"available": available, "configured": selected is not None,
300
+ "base_url": selected["base_url"] if selected else None,
301
+ "model": selected.get("model") if selected else None,
302
+ "credential_id": selected.get("credential_id") if selected else None,
303
+ "has_api_key": selected is not None, "wire_api": "responses"}
304
+
305
+ def for_session(self, session: dict, *, include_key=False) -> dict | None:
306
+ if session_choice(session.get("codex_provider")) == "default":
307
+ return None
308
+ revision = session.get("codex_provider_revision")
309
+ selected = self.selection(include_key=include_key, revision=revision, include_revision=True)
310
+ if selected is None:
311
+ raise HTTPException(409, "Configure the custom Codex endpoint before using this chat.")
312
+ if session.get("codex_provider_binding") not in (None, binding(selected), legacy_binding(selected)):
313
+ raise HTTPException(409, "This conversation belongs to another Codex endpoint. Start a new Codex chat, or restore its original endpoint.")
314
+ model = session.get("model") or selected.get("model") or self.cached_catalog(selected).get("default_model")
315
+ if model:
316
+ selected["model"] = validate_model(model)
317
+ return selected
318
+
319
+ def registration(self, *, include_key=False) -> dict | None:
320
+ # A damaged optional endpoint must not prevent normal Codex startup.
321
+ # Custom chat admission and the admin status route still fail closed.
322
+ try:
323
+ return self.selection(include_key=include_key, include_revision=True)
324
+ except HTTPException:
325
+ return None
326
+
327
+ def catalog(self, *, available: bool, session: dict | None = None, summary=False) -> dict:
328
+ try:
329
+ # Immutable revisions permit public projections to reuse validated
330
+ # metadata without reopening credential files for every event.
331
+ selected = self._public_selections.get(session.get("codex_provider_revision")) if session else None
332
+ selected = selected or (self.for_session(session) if session is not None else self.registration())
333
+ except HTTPException:
334
+ selected = None
335
+ result = {"configured": selected is not None, "available": available and selected is not None,
336
+ "model": selected.get("model") if selected else None,
337
+ "base_url": selected["base_url"] if selected else None,
338
+ "credential_id": selected.get("credential_id") if selected else None,
339
+ **(self.cached_catalog(selected) if selected else {"models": [], "efforts": [],
340
+ "model_efforts": {}, "model_capabilities": {}, "default_model": "", "default_effort": ""})}
341
+ if summary:
342
+ result.pop("models", None)
343
+ result.pop("model_efforts", None)
344
+ result.pop("model_capabilities", None)
345
+ return result
346
+
347
+ def cached_catalog(self, selected: dict | None = None) -> dict:
348
+ with self.lock:
349
+ if selected is None:
350
+ selected = self.registration(include_key=True)
351
+ identifier = (selected or {}).get("credential_id")
352
+ if selected and "api_key" not in selected and identifier not in self._revision_catalog_keys:
353
+ selected = self.selection(include_key=True, revision=selected.get("credential_id"))
354
+ key = (catalog_key(selected) if "api_key" in selected else self._revision_catalog_keys.get(identifier)) if selected else None
355
+ cached = self._catalogs.get(key)
356
+ if cached is None:
357
+ cached = self._saved_model_catalog(selected)
358
+ capabilities = {model["value"]: model_capability(cached.get("model_capabilities", {}).get(model["value"]))
359
+ for model in cached.get("models", [])}
360
+ for model, evidence in self._model_capabilities.get(key, {}).items():
361
+ capabilities[model] = model_capability({**capabilities.get(model, {}), **evidence})
362
+ for model, supported in self._saved_summary_capabilities(selected).items():
363
+ capabilities[model] = model_capability({**capabilities.get(model, {}), "reasoning_summary_supported": supported})
364
+ if selected and selected.get("model"):
365
+ capabilities.setdefault(selected["model"], model_capability())
366
+ return {"models": cached.get("models", []), "efforts": [],
367
+ "model_efforts": {model: [option for option in EFFORT_OPTIONS if option["value"] in capability["reasoning_efforts"]]
368
+ for model, capability in capabilities.items()},
369
+ "model_capabilities": capabilities,
370
+ "default_model": cached.get("default_model") or (selected or {}).get("model") or "",
371
+ "default_effort": ""}
372
+
373
+ def cache_catalog(self, selected: dict, catalog: dict):
374
+ with self.lock:
375
+ models = catalog.get("models", [])
376
+ self._catalogs[catalog_key(selected)] = {"models": models,
377
+ "model_capabilities": {model["value"]: model_capability(catalog.get("model_capabilities", {}).get(model["value"])) for model in models},
378
+ "default_model": catalog.get("default_model", "")}
379
+ self._save_model_catalog(selected, self._catalogs[catalog_key(selected)])
380
+ # Apply fresh explicit denials when discovery completes, rather
381
+ # than letting old metadata veto a later successful model check.
382
+ denials = {model["value"]: False for model in models
383
+ if catalog.get("model_capabilities", {}).get(model["value"], {}).get("reasoning_summary_supported") is False}
384
+ if denials:
385
+ self._cache_summary_capabilities(selected, denials)
386
+
387
+ def _save_model_catalog(self, selected: dict, catalog: dict) -> None:
388
+ identifier = selected.get("credential_id")
389
+ if not isinstance(identifier, str) or not re.fullmatch(r"[0-9a-f]{32}", identifier):
390
+ return
391
+ saved = self.selection(include_key=True, revision=identifier)
392
+ if not saved or catalog_key(saved) != catalog_key(selected):
393
+ raise HTTPException(409, "The saved endpoint changed. Refresh before discovering models.")
394
+ # Basic-check status and summary observations keep their independent
395
+ # lifetimes. Only the discovered model list and effort choices reload.
396
+ metadata = {**catalog, "model_capabilities": {model: {
397
+ name: value for name, value in capability.items()
398
+ if name not in {"compatibility", "reasoning_summary_supported"}}
399
+ for model, capability in catalog["model_capabilities"].items()}}
400
+ record = {"version": 1, "credential_id": identifier, "binding": binding(saved),
401
+ "catalog_key": catalog_key(saved), "catalog": metadata}
402
+ if len(json.dumps(record, separators=(",", ":")).encode()) > MAX_MODEL_CATALOG_BYTES:
403
+ return
404
+ try:
405
+ self._atomic("model-catalog-" + identifier + ".json", record)
406
+ except (OSError, HTTPException):
407
+ pass
408
+ self._saved_catalogs[identifier] = metadata
409
+
410
+ def _saved_model_catalog(self, selected: dict | None) -> dict:
411
+ identifier = (selected or {}).get("credential_id")
412
+ if not isinstance(identifier, str) or not re.fullmatch(r"[0-9a-f]{32}", identifier):
413
+ return {}
414
+ if identifier in self._saved_catalogs:
415
+ return self._saved_catalogs[identifier]
416
+ try:
417
+ if identifier not in self._revision_catalog_keys:
418
+ self.selection(revision=identifier)
419
+ expected_key = self._revision_catalog_keys.get(identifier)
420
+ if "api_key" in selected and catalog_key(selected) != expected_key:
421
+ return {}
422
+ record = self._read("model-catalog-" + identifier + ".json", max_bytes=MAX_MODEL_CATALOG_BYTES)
423
+ if record is None:
424
+ self._saved_catalogs[identifier] = {}
425
+ return {}
426
+ catalog = record.get("catalog")
427
+ if (record.get("version") != 1 or record.get("credential_id") != identifier
428
+ or record.get("binding") != binding(selected)
429
+ or record.get("catalog_key") != expected_key
430
+ or not isinstance(catalog, dict) or not isinstance(catalog.get("models"), list)
431
+ or len(catalog["models"]) > 512 or not isinstance(catalog.get("model_capabilities"), dict)):
432
+ raise ValueError("Invalid saved model catalog")
433
+ models = [{"value": validate_model(model.get("value")), "label": validate_model(model.get("value"))}
434
+ for model in catalog["models"]]
435
+ if len({model["value"] for model in models}) != len(models):
436
+ raise ValueError("Duplicate saved model identities")
437
+ capabilities = {model["value"]: model_capability({
438
+ **catalog["model_capabilities"].get(model["value"], {}),
439
+ "compatibility": "unverified", "reasoning_summary_supported": None}) for model in models}
440
+ 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}
444
+ self._saved_catalogs[identifier] = result
445
+ return result
446
+ except Exception:
447
+ # Optional catalog metadata never blocks opening a custom chat.
448
+ return {}
449
+
450
+ def cache_model_capability(self, selected: dict, capability: dict):
451
+ """Retain only probe evidence for this exact endpoint/key/model tuple."""
452
+ model = validate_model(selected.get("model"))
453
+ with self.lock:
454
+ records = self._model_capabilities.setdefault(catalog_key(selected), {})
455
+ previous = records.get(model, {})
456
+ cleaned = model_capability({**previous, **capability})
457
+ records[model] = {name: cleaned[name] for name in cleaned
458
+ if name != "reasoning_summary_supported" and (name in previous or name in capability)}
459
+ supported = capability.get("reasoning_summary_supported")
460
+ if isinstance(supported, bool):
461
+ self._cache_summary_capabilities(selected, {model: supported})
462
+
463
+ def _cache_summary_capabilities(self, selected: dict, observations: dict[str, bool]) -> None:
464
+ identifier = selected.get("credential_id")
465
+ if not isinstance(identifier, str) or not re.fullmatch(r"[0-9a-f]{32}", identifier):
466
+ return
467
+ # Entered, unsaved credentials never create durable evidence.
468
+ # Validate the claimed immutable revision before using its name.
469
+ saved = self.selection(include_key=True, revision=identifier)
470
+ if not saved or catalog_key(saved) != catalog_key(selected):
471
+ raise HTTPException(409, "The saved endpoint changed. Refresh before checking this model.")
472
+ summaries = dict(self._saved_summary_capabilities(saved))
473
+ for model, supported in observations.items():
474
+ summaries.pop(model, None)
475
+ summaries[model] = supported
476
+ while len(summaries) > 512:
477
+ summaries.pop(next(iter(summaries)))
478
+ try:
479
+ self._atomic("summary-capabilities-" + identifier + ".json", {
480
+ "version": 1, "credential_id": identifier, "binding": binding(saved),
481
+ "catalog_key": catalog_key(saved), "models": summaries})
482
+ except (OSError, HTTPException):
483
+ # A successful optional check remains useful for this
484
+ # process even when display metadata cannot be persisted.
485
+ pass
486
+ self._summary_capabilities[identifier] = summaries
487
+
488
+ def _saved_summary_capabilities(self, selected: dict | None) -> dict[str, bool]:
489
+ """Private summary evidence belongs to one saved credential revision."""
490
+ identifier = (selected or {}).get("credential_id")
491
+ if not isinstance(identifier, str) or not re.fullmatch(r"[0-9a-f]{32}", identifier):
492
+ return {}
493
+ if identifier in self._summary_capabilities:
494
+ return self._summary_capabilities[identifier]
495
+ try:
496
+ record = self._read("summary-capabilities-" + identifier + ".json", max_bytes=MAX_SUMMARY_CAPABILITIES_BYTES)
497
+ if record is None:
498
+ models = {}
499
+ else:
500
+ models = record.get("models")
501
+ if (record.get("version") != 1 or record.get("credential_id") != identifier
502
+ or record.get("binding") != binding(selected)
503
+ or record.get("catalog_key") != self._revision_catalog_keys.get(identifier)
504
+ or not isinstance(models, dict) or len(models) > 512
505
+ or any(validate_model(model) != model or not isinstance(supported, bool) for model, supported in models.items())):
506
+ raise ValueError("Invalid summary capability evidence")
507
+ self._summary_capabilities[identifier] = models
508
+ return models
509
+ except Exception:
510
+ # Optional display capability evidence must never prevent a chat or
511
+ # catalog from opening. Credential files retain strict validation.
512
+ return {}
513
+
514
+ def revision(self):
515
+ with self.lock:
516
+ return (self._read("settings.json") or {}).get("credential_id")
517
+
518
+ def _binding_name(self, thread_id: str) -> str:
519
+ return "thread-" + hashlib.sha256(thread_id.encode()).hexdigest() + ".json"
520
+
521
+ def require_thread(self, thread_id: str, selected: dict | None):
522
+ if not thread_id:
523
+ return
524
+ with self.lock:
525
+ previous = self._read(self._binding_name(thread_id))
526
+ expected = binding(selected) if selected else None
527
+ accepted = {expected}
528
+ if selected:
529
+ accepted.add(legacy_binding(selected))
530
+ accepted.discard(None)
531
+ if (previous or {}).get("binding") not in accepted or (
532
+ previous and previous.get("credential_id") and
533
+ previous["credential_id"] != (selected or {}).get("credential_id")
534
+ ):
535
+ raise HTTPException(409, "This conversation belongs to another Codex endpoint. Start a new Codex chat, or restore its original endpoint.")
536
+
537
+ def record_thread(self, thread_id: str, selected: dict | None):
538
+ with self.lock:
539
+ if selected:
540
+ self._atomic(self._binding_name(thread_id), {"binding": binding(selected),
541
+ "credential_id": selected.get("credential_id")})
542
+
543
+ def for_thread(self, thread_id: str, *, include_key=False) -> dict | None:
544
+ with self.lock:
545
+ record = self._read(self._binding_name(thread_id)) or {}
546
+ revision = record.get("credential_id")
547
+ if revision:
548
+ return self.selection(revision=revision, include_key=include_key, include_revision=True)
549
+ if record.get("binding"):
550
+ selected = self.registration(include_key=include_key)
551
+ if selected and record["binding"] in (binding(selected), legacy_binding(selected)):
552
+ return selected
553
+ raise HTTPException(409, "This conversation's original Codex endpoint is unavailable.")
554
+ return None
555
+
556
+ def retain_current(self) -> dict | None:
557
+ """Make the current generation independently readable before replacement."""
558
+ selected = self.selection(include_key=True, include_revision=True)
559
+ if selected:
560
+ self._atomic("credential-" + selected["credential_id"] + ".json",
561
+ {**selected, "binding": binding(selected)})
562
+ for path in self.root.glob("thread-*.json"):
563
+ previous = self._read(path.name) or {}
564
+ if not previous.get("credential_id") and previous.get("binding") and previous["binding"] in (binding(selected), legacy_binding(selected)):
565
+ self._atomic(path.name, {"binding": binding(selected), "credential_id": selected["credential_id"]})
566
+ return selected
567
+
568
+ def save(self, selected: dict):
569
+ with self.lock:
570
+ try:
571
+ self.retain_current()
572
+ except HTTPException:
573
+ # A missing old credential must not prevent an operator from
574
+ # repairing the current selection with a fresh credential.
575
+ pass
576
+ identifier = uuid.uuid4().hex
577
+ credential_name = "credential-" + identifier + ".json"
578
+ self._atomic(credential_name, {**selected, "binding": binding(selected)})
579
+ try:
580
+ self._atomic("settings.json", {name: value for name, value in
581
+ {**selected, "credential_id": identifier}.items() if name != "api_key"})
582
+ except BaseException:
583
+ with suppress(OSError):
584
+ os.unlink(self.root / credential_name)
585
+ raise
586
+
587
+ def reset(self):
588
+ with self.lock:
589
+ try:
590
+ self.retain_current()
591
+ except (HTTPException, ValueError, UnicodeError):
592
+ pass
593
+ self._atomic("settings.json", {})
594
+
595
+
596
+ def native_config(selected: dict) -> dict:
597
+ return {"model_provider": PROVIDER_ID, **({"model": selected["model"]} if selected.get("model") else {}),
598
+ "model_reasoning_summary": "auto" if selected.get("reasoning_summary") == "auto" else "none",
599
+ "model_providers": {PROVIDER_ID: {"name": "AgentsDock custom endpoint",
600
+ "base_url": selected["base_url"], "env_key": ENV_KEY, "requires_openai_auth": False,
601
+ "wire_api": "responses",
602
+ "supports_websockets": False}},
603
+ "shell_environment_policy.exclude": [ENV_KEY, "OPENAI_API_KEY", "CODEX_API_KEY", "OPENAI_AUTH_TOKEN"]}
604
+
605
+
606
+ def native_args(selected: dict) -> tuple[str, ...]:
607
+ return config_args(native_config(selected))
608
+
609
+
610
+ def registration_args(selected: dict) -> tuple[str, ...]:
611
+ config = native_config(selected)
612
+ config.pop("model_provider")
613
+ config.pop("model", None)
614
+ # The client merges the dedicated secret exclusion per thread after
615
+ # resolving native profile/project layers for that thread's cwd.
616
+ config.pop("shell_environment_policy.exclude")
617
+ return config_args(config)
618
+
619
+
620
+ def registration_environment(environment: dict, selected: dict) -> dict:
621
+ return native_environment(environment, selected)
622
+
623
+
624
+ def config_args(config: dict) -> tuple[str, ...]:
625
+ # CLI overrides consume TOML, not JSON objects. Primitive and array JSON
626
+ # values are TOML-compatible; maps require separate dotted keys.
627
+ result = []
628
+ def add(name, value):
629
+ if isinstance(value, dict):
630
+ for key, child in value.items():
631
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", key):
632
+ raise ValueError("unsupported native configuration key")
633
+ # Codex's override parser splits paths itself and retains
634
+ # quotes literally; our fixed schema uses bare safe keys.
635
+ add(name + "." + key, child)
636
+ else:
637
+ result.extend(("-c", name + "=" + json.dumps(value)))
638
+ for name, value in config.items():
639
+ add(name, value)
640
+ return tuple(result)
641
+
642
+
643
+ def native_catalog_without_reasoning_defaults(payload: object) -> dict:
644
+ """Retain native tools/instructions without assuming gateway reasoning support."""
645
+ models = payload.get("models") if isinstance(payload, dict) else None
646
+ if not isinstance(models, list) or not 1 <= len(models) <= 512:
647
+ raise ValueError("Native model catalog is unavailable")
648
+ corrected, seen = [], set()
649
+ for model in models:
650
+ if not isinstance(model, dict):
651
+ raise ValueError("Native model catalog is invalid")
652
+ slug = validate_model(model.get("slug"))
653
+ if slug in seen:
654
+ raise ValueError("Native model catalog has duplicate identities")
655
+ seen.add(slug)
656
+ corrected.append({**model,
657
+ "default_reasoning_level": None,
658
+ "default_reasoning_summary": "none",
659
+ # Permit explicit per-turn requests. Endpoint/model evidence gates
660
+ # them in runtime_summary; native or inherited defaults stay off.
661
+ "supports_reasoning_summary_parameter": True,
662
+ "supports_experimental_context": False,
663
+ "multi_agent_reasoning_effort": None,
664
+ # The internal Lite serializer adds reasoning.context regardless
665
+ # of supports_experimental_context. Custom endpoints use Responses.
666
+ "use_responses_lite": False,
667
+ })
668
+ return {"models": corrected}
669
+
670
+
671
+ async def prepare_native_catalog(executable: str, environment: dict, target_path: Path) -> str:
672
+ """Build an owned custom-runtime catalog from this executable's offline metadata."""
673
+ target = Path(target_path).absolute()
674
+ storage = ProviderStore(target.parent)
675
+ try:
676
+ storage._directory(create=True)
677
+ with tempfile.TemporaryDirectory(prefix=".catalog-source-", dir=target.parent) as temporary:
678
+ env = {key: value for key, value in environment.items()
679
+ if key in {"PATH", "LANG", "LC_ALL", "SYSTEMROOT", "WINDIR", "PATHEXT"}}
680
+ env.update({"HOME": temporary, "CODEX_HOME": temporary, "TMPDIR": temporary,
681
+ "TMP": temporary, "TEMP": temporary, "RUST_LOG": "off", "OTEL_SDK_DISABLED": "true"})
682
+ config = {**isolated_config(), "cli_auth_credentials_store": "ephemeral",
683
+ "history.persistence": "none", "check_for_update_on_startup": False,
684
+ "analytics.enabled": False, "otel.exporter": "none", "otel.trace_exporter": "none",
685
+ "model_provider": "agentsdock_catalog",
686
+ "model_providers": {"agentsdock_catalog": {"name": "Offline native catalog",
687
+ "base_url": "http://127.0.0.1:9/v1", "requires_openai_auth": False,
688
+ "wire_api": "responses", "request_max_retries": 0, "stream_max_retries": 0}}}
689
+ # This existing owned-process helper caps each output stream at
690
+ # 1 MiB and joins cancellation/timeout cleanup before returning.
691
+ raw = await run_isolated_command(
692
+ [executable, *config_args(config), "debug", "models"],
693
+ prompt="", cwd=temporary, env=env, timeout=15,
694
+ )
695
+ catalog = native_catalog_without_reasoning_defaults(json.loads(raw))
696
+ # Reuse the private atomic writer; the catalog contains metadata,
697
+ # never endpoint credentials or the user's native configuration.
698
+ storage._atomic(target.name, catalog)
699
+ return str(target)
700
+ except asyncio.CancelledError:
701
+ raise
702
+ except Exception:
703
+ raise HTTPException(503, "The native Codex model catalog could not be prepared. Update or repair Codex and retry.") from None
704
+
705
+
706
+ def native_environment(environment: dict, selected: dict) -> dict:
707
+ clean = {name: value for name, value in environment.items() if name.upper() not in {
708
+ "OPENAI_API_KEY", "CODEX_API_KEY", "OPENAI_AUTH_TOKEN", "OPENAI_BASE_URL", "OPENAI_API_BASE",
709
+ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", ENV_KEY}}
710
+ clean[ENV_KEY] = selected["api_key"]
711
+ clean["RUST_LOG"] = "off"
712
+ return clean
713
+
714
+
715
+ class _NoModelRedirects(HTTPRedirectHandler):
716
+ def redirect_request(self, *_args, **_kwargs):
717
+ return None
718
+
719
+
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"})
724
+ 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": [],
755
+ "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")
770
+
771
+
772
+ def test_result(status: str) -> dict:
773
+ messages = {"ready": "Basic compatibility checked: native isolated tools and a follow-up response completed. Workspace tools, integrations and reasoning effort settings were not checked.",
774
+ "unsupported": "The endpoint did not accept the Responses protocol required by Codex.",
775
+ "unsupported_parameter": "The endpoint rejected a parameter required by this native Codex request. Check this model's Responses compatibility and supported settings.",
776
+ "authentication_failed": "The endpoint rejected the provider API key.",
777
+ "model_unavailable": "The endpoint did not accept this model ID.",
778
+ "connection_failed": "The connection test could not reach the endpoint or timed out.",
779
+ "inconclusive": "The model did not complete the isolated tool check and follow-up. Compatibility remains unverified.",
780
+ "failed": "The native Codex connection test did not complete. Check the endpoint, model and provider access."}
781
+ return {"ok": status == "ready", "status": status, "message": messages[status]}
782
+
783
+
784
+ def classify_failure(value) -> str:
785
+ # Inspect in memory only; upstream error messages may contain credentials.
786
+ if isinstance(value, (TimeoutError, ConnectionError)):
787
+ return "connection_failed"
788
+ text = str(value).lower()
789
+ if any(marker in text for marker in ("401", "403", "invalid_api_key", "unauthorized", "forbidden", "incorrect api key")):
790
+ return "authentication_failed"
791
+ if any(marker in text for marker in ("model_not_found", "model not found", "unknown model", "model does not exist")):
792
+ return "model_unavailable"
793
+ if any(marker in text for marker in ("unsupported_parameter", "unsupported parameter", "unsupported_value", "unknown parameter", "unrecognized request argument", "unrecognized parameter")) or (
794
+ any(marker in text for marker in ("reasoning.effort", "reasoning effort", "reasoning_effort", "reasoning.summary", "reasoning summary", "reasoning_summary", "tool_choice", "tools[", "tools.", "tool type", "tool format", "custom tool", "freeform"))
795
+ and any(marker in text for marker in ("not supported", "unsupported", "invalid parameter", "invalid value", "unknown field"))
796
+ ):
797
+ return "unsupported_parameter"
798
+ if any(marker in text for marker in ("405", "unsupported protocol", "/responses is not supported", "cannot post /v1/responses")):
799
+ return "unsupported"
800
+ if any(marker in text for marker in ("connection refused", "dns error", "failed to lookup address", "connection timed out", "request timed out", "connection failed:", "error sending request")):
801
+ return "connection_failed"
802
+ return "failed"
803
+
804
+
805
+ async def test_connection(selected: dict, *, executable: str, environment: dict,
806
+ manager_factory=CodexAppServerManager, verify_protocol=_verify_protocol) -> dict:
807
+ native = None
808
+ opening = None
809
+ checks = {"native_tool_call": False, "tool_roundtrip": False, "continuation": False}
810
+ token = "COMPATIBILITY_" + uuid.uuid4().hex
811
+ ping_count = 0
812
+ unexpected_request = False
813
+ thread = None
814
+ basic_ready = False
815
+ summary_supported = None
816
+ summary_check = "not_checked"
817
+
818
+ def observe_summary_failure(error):
819
+ nonlocal summary_supported, summary_check
820
+ failure = str(error).lower()
821
+ if (classify_failure(error) == "unsupported_parameter"
822
+ and any(marker in failure for marker in ("reasoning.summary", "reasoning_summary", "reasoning summary"))):
823
+ summary_supported, summary_check = False, "unsupported"
824
+
825
+ def result(status):
826
+ compatibility = ("verified" if status == "ready" else "unsupported"
827
+ if status in {"unsupported", "unsupported_parameter", "model_unavailable"} else "unverified")
828
+ return {**test_result(status), "model": selected["model"], "compatibility": compatibility,
829
+ "scope": "isolated_native_tools_and_continuation", "checks": dict(checks),
830
+ "reasoning_summary_supported": summary_supported, "summary_check": summary_check}
831
+
832
+ async def handle_request(request_id, method, params):
833
+ nonlocal ping_count, unexpected_request
834
+ if method == "item/tool/call":
835
+ if (params.get("threadId") == thread and params.get("tool") == TEST_TOOL
836
+ and params.get("arguments") == {} and ping_count == 0):
837
+ ping_count += 1
838
+ return {"success": True, "contentItems": [{"type": "inputText", "text": token}]}
839
+ unexpected_request = True
840
+ return {"success": False, "contentItems": [{"type": "inputText", "text": "Only the compatibility ping is available."}]}
841
+ unexpected_request = True
842
+ return await decline_server_request(request_id, method, params)
843
+
844
+ with tempfile.TemporaryDirectory(prefix="agentsdock-provider-test-") as temporary:
845
+ config = {**isolated_config(), **native_config(selected), "cli_auth_credentials_store": "ephemeral",
846
+ "tools.update_plan.enabled": True, "developer_instructions": TEST_INSTRUCTIONS,
847
+ "log_dir": str(Path(temporary) / "logs"), "sqlite_home": str(Path(temporary) / "db"),
848
+ "history.persistence": "none", "check_for_update_on_startup": False,
849
+ "analytics.enabled": False, "otel.exporter": "none", "otel.trace_exporter": "none"}
850
+ # Connection checks are one explicit attempt; normal chats retain
851
+ # native bounded HTTP/stream recovery for transient failures.
852
+ config["model_providers"][PROVIDER_ID].update(request_max_retries=0, stream_max_retries=0)
853
+ env = native_environment(isolated_environment(environment), selected)
854
+ # A fresh SQLite database paired with the user's Codex home triggers
855
+ # a full history reindex before native initialization can complete.
856
+ # The probe needs only its explicit endpoint/model/key, so isolate its
857
+ # configuration, auth and history together without changing HOME.
858
+ env.update({"CODEX_HOME": temporary, "TMPDIR": temporary, "OTEL_SDK_DISABLED": "true"})
859
+ try:
860
+ async with asyncio.timeout(TEST_TIMEOUT_SECONDS):
861
+ await verify_protocol(executable, temporary, {name: value for name, value in env.items() if name != ENV_KEY})
862
+ config["model_catalog_json"] = await prepare_native_catalog(
863
+ executable, env, Path(temporary) / "models.json",
864
+ )
865
+ args = config_args(config)
866
+ native = manager_factory(executable, cwd=temporary, env_factory=lambda: env,
867
+ app_server_args=args, request_timeout=15, lifecycle_timeout=15,
868
+ server_request_handler=handle_request,
869
+ sensitive_values=(selected["api_key"],))
870
+ # Env-carried credentials deserve the same diagnostic suppression
871
+ # as native account/login/start, including startup diagnostics.
872
+ native.client._authentication_submitted = True
873
+ # Retain ownership if cancellation races subprocess spawn.
874
+ opening = asyncio.create_task(native.start())
875
+ await asyncio.shield(opening)
876
+ effective = await native.request("config/read", {"includeLayers": False})
877
+ settings = effective.get("config", {})
878
+ provider = settings.get("model_providers", {}).get(PROVIDER_ID, {})
879
+ if settings.get("cli_auth_credentials_store") != "ephemeral" or settings.get("model_provider") != PROVIDER_ID or provider.get("requires_openai_auth") is not False or provider.get("env_key") != ENV_KEY or provider.get("base_url") != selected["base_url"]:
880
+ return result("failed")
881
+ servers = settings.get("mcp_servers", {})
882
+ if not isinstance(servers, dict):
883
+ return result("failed")
884
+ config["mcp_servers"] = {name: {"enabled": False} for name in servers}
885
+ thread = await native.start_thread({"ephemeral": True, "cwd": temporary, "model": selected["model"],
886
+ "modelProvider": PROVIDER_ID, "approvalPolicy": "never", "sandbox": "read-only",
887
+ "baseInstructions": TEST_INSTRUCTIONS, "developerInstructions": TEST_INSTRUCTIONS,
888
+ "config": config, "dynamicTools": [{"name": TEST_TOOL,
889
+ "description": "Harmless in-memory compatibility check. Returns a random token; performs no external action.",
890
+ "inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}}],
891
+ "environments": []})
892
+ metadata = await native.read_thread(thread, include_turns=False)
893
+ if metadata.get("ephemeral") is not True or metadata.get("path") is not None:
894
+ return result("failed")
895
+ for index, prompt in enumerate((TEST_PROMPT,
896
+ "Repeat exactly the token returned by the compatibility ping in the preceding turn. Do not call any tools.")):
897
+ turn = await native.start_turn(thread, [{"type": "text", "text": prompt}],
898
+ overrides={**turn_overrides(selected["model"]), "environments": []})
899
+ answer = ""
900
+ try:
901
+ while True:
902
+ packet = await turn.next_notification()
903
+ data = packet.get("params", {})
904
+ if packet.get("method") == "error":
905
+ # One explicit attempt; never silently retry a probe.
906
+ return result(classify_failure(data.get("error") or data.get("message")))
907
+ if packet.get("method") == "turn/plan/updated":
908
+ checks["native_tool_call"] = True
909
+ if packet.get("method") == "item/completed" and data.get("item", {}).get("type") == "agentMessage":
910
+ answer = str(data["item"].get("text") or "").strip()
911
+ if packet.get("method") == "turn/completed":
912
+ completed = data.get("turn", {})
913
+ if completed.get("status") != "completed" or completed.get("error"):
914
+ return result(classify_failure(completed.get("error")))
915
+ if unexpected_request or ping_count != 1 or answer != token or not checks["native_tool_call"]:
916
+ return result("inconclusive")
917
+ checks["tool_roundtrip" if index == 0 else "continuation"] = True
918
+ break
919
+ finally:
920
+ await turn.close()
921
+ basic_ready = True
922
+ # Optional evidence from this explicit operator check only.
923
+ # Missing summaries or an unrelated failure never invalidate
924
+ # the completed basic tool/continuation check.
925
+ summary_check = "inconclusive"
926
+ try:
927
+ async with asyncio.timeout(10):
928
+ turn = await native.start_turn(thread, [{"type": "text", "text": SUMMARY_TEST_PROMPT}],
929
+ overrides={**turn_overrides(selected["model"], summary="auto"), "environments": []})
930
+ observed_summary = False
931
+ try:
932
+ while True:
933
+ packet = await turn.next_notification()
934
+ data = packet.get("params", {})
935
+ method = packet.get("method")
936
+ if method == "item/reasoning/summaryTextDelta" and isinstance(data.get("delta"), str) and data["delta"].strip():
937
+ observed_summary = True
938
+ if method == "item/completed" and data.get("item", {}).get("type") == "reasoning":
939
+ summaries = data["item"].get("summary")
940
+ if isinstance(summaries, list) and any(isinstance(text, str) and text.strip() for text in summaries):
941
+ observed_summary = True
942
+ error = (data.get("error") or data.get("message")) if method == "error" else None
943
+ if method == "turn/completed":
944
+ completed = data.get("turn", {})
945
+ error = completed.get("error")
946
+ if completed.get("status") == "completed" and not error and observed_summary and not unexpected_request:
947
+ summary_supported, summary_check = True, "supported"
948
+ if error:
949
+ observe_summary_failure(error)
950
+ if error or method == "turn/completed":
951
+ break
952
+ finally:
953
+ await turn.close()
954
+ except asyncio.CancelledError:
955
+ raise
956
+ except Exception as exc:
957
+ observe_summary_failure(exc)
958
+ return result("ready")
959
+ except asyncio.CancelledError:
960
+ raise
961
+ except Exception as exc:
962
+ return result("ready" if basic_ready else classify_failure(exc))
963
+ finally:
964
+ async def cleanup():
965
+ if opening is not None:
966
+ with suppress(BaseException):
967
+ await opening
968
+ if native is not None:
969
+ await native.close()
970
+ closing = asyncio.create_task(cleanup())
971
+ cancelled = False
972
+ try:
973
+ while not closing.done():
974
+ try:
975
+ await asyncio.shield(closing)
976
+ except asyncio.CancelledError:
977
+ cancelled = True
978
+ closing.result()
979
+ finally:
980
+ env.clear()
981
+ if cancelled:
982
+ raise asyncio.CancelledError
983
+
984
+
985
+ def create_router(*, authorize, store: ProviderStore, mutate, probe, available, discover=discover_models, session_lookup=None, native_models=None) -> APIRouter:
986
+ router = APIRouter()
987
+
988
+ async def body(request, *, saved=False):
989
+ raw = bytearray()
990
+ async for chunk in request.stream():
991
+ raw.extend(chunk)
992
+ if len(raw) > MAX_BODY_BYTES:
993
+ raise HTTPException(413, "Provider request is too large.")
994
+ try:
995
+ parsed = json.loads(raw)
996
+ if saved and isinstance(parsed, dict) and "base_url" not in parsed and "api_key" not in parsed:
997
+ if set(parsed) - {"model", "session_id", "credential_id"}:
998
+ raise HTTPException(400, "Provide a model and the saved endpoint identity.")
999
+ selected_model = validate_model(parsed.get("model"))
1000
+ session_id, revision = parsed.get("session_id"), parsed.get("credential_id")
1001
+ if session_id is not None and (not isinstance(session_id, str) or not 1 <= len(session_id) <= 256):
1002
+ raise HTTPException(400, "Provide a valid chat identity.")
1003
+ if revision is not None and (not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{32}", revision)):
1004
+ raise HTTPException(400, "Provide a valid saved endpoint identity.")
1005
+ if session_id is not None:
1006
+ session = session_lookup(session_id) if session_lookup else None
1007
+ if not session:
1008
+ raise HTTPException(404, "Chat not found.")
1009
+ selected = await asyncio.to_thread(store.for_session, session, include_key=True)
1010
+ else:
1011
+ selected = await asyncio.to_thread(store.selection, include_key=True, include_revision=True)
1012
+ if selected is None:
1013
+ raise HTTPException(409, "Save a custom endpoint before checking a model.")
1014
+ if revision is not None and selected.get("credential_id") != revision:
1015
+ selected.clear()
1016
+ raise HTTPException(409, "The saved endpoint changed. Refresh before checking this model.")
1017
+ return {**selected, "model": selected_model}
1018
+ return validate_selection(parsed)
1019
+ except (ValueError, UnicodeError, RecursionError):
1020
+ raise HTTPException(400, "Provide a valid provider request.") from None
1021
+ finally:
1022
+ raw.clear()
1023
+
1024
+ def access(request):
1025
+ authorize(request)
1026
+ if not available():
1027
+ raise HTTPException(503, "Custom providers require native Codex app-server transport.")
1028
+
1029
+ 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)
1040
+ if result.get("ok") is True:
1041
+ store.cache_catalog(selected, result)
1042
+ return {**result, **store.cached_catalog(selected)}
1043
+ return result
1044
+
1045
+ @router.get("/api/admin/codex/provider")
1046
+ async def status(request: Request):
1047
+ authorize(request)
1048
+ return JSONResponse(await asyncio.to_thread(store.status, available=available()), headers={"Cache-Control": "no-store"})
1049
+
1050
+ @router.post("/api/admin/codex/provider/test")
1051
+ async def test(request: Request):
1052
+ access(request)
1053
+ selected = await body(request, saved=True)
1054
+ try:
1055
+ result = await probe(selected) if selected.get("model") else await catalog(selected)
1056
+ if selected.get("model") and result.get("compatibility") in {"verified", "unsupported", "unverified"}:
1057
+ evidence = {"compatibility": result["compatibility"]}
1058
+ if isinstance(result.get("reasoning_summary_supported"), bool):
1059
+ evidence["reasoning_summary_supported"] = result["reasoning_summary_supported"]
1060
+ store.cache_model_capability(selected, evidence)
1061
+ return JSONResponse(result, headers={"Cache-Control": "no-store"})
1062
+ except HTTPException:
1063
+ raise
1064
+ except Exception:
1065
+ raise HTTPException(503, "Provider test is unavailable.") from None
1066
+ finally:
1067
+ selected.clear()
1068
+
1069
+ @router.get("/api/admin/codex/provider/models")
1070
+ async def models(request: Request, session_id: str | None = None):
1071
+ access(request)
1072
+ if session_id is not None:
1073
+ session = session_lookup(session_id) if session_lookup else None
1074
+ if not session:
1075
+ raise HTTPException(404, "Chat not found.")
1076
+ selected = await asyncio.to_thread(store.for_session, session, include_key=True)
1077
+ else:
1078
+ selected = await asyncio.to_thread(store.selection, include_key=True, include_revision=True)
1079
+ if selected is None:
1080
+ raise HTTPException(409, "Save a custom endpoint before refreshing its models.")
1081
+ try:
1082
+ return JSONResponse(await catalog(selected), headers={"Cache-Control": "no-store"})
1083
+ finally:
1084
+ selected.clear()
1085
+
1086
+ @router.post("/api/admin/codex/provider/models")
1087
+ async def entered_models(request: Request):
1088
+ access(request)
1089
+ selected = await body(request)
1090
+ try:
1091
+ return JSONResponse(await catalog(selected), headers={"Cache-Control": "no-store"})
1092
+ finally:
1093
+ selected.clear()
1094
+
1095
+ @router.put("/api/admin/codex/provider")
1096
+ async def save(request: Request):
1097
+ access(request)
1098
+ selected = await body(request)
1099
+ try:
1100
+ await mutate(selected)
1101
+ return JSONResponse(await asyncio.to_thread(store.status), headers={"Cache-Control": "no-store"})
1102
+ except HTTPException:
1103
+ raise
1104
+ except Exception:
1105
+ raise HTTPException(503, "Provider settings could not be saved.") from None
1106
+ finally:
1107
+ selected.clear()
1108
+
1109
+ @router.delete("/api/admin/codex/provider")
1110
+ async def reset(request: Request):
1111
+ access(request)
1112
+ try:
1113
+ await mutate(None)
1114
+ return JSONResponse(await asyncio.to_thread(store.status), headers={"Cache-Control": "no-store"})
1115
+ except HTTPException:
1116
+ raise
1117
+ except Exception:
1118
+ raise HTTPException(503, "Provider settings could not be reset.") from None
1119
+
1120
+ return router