@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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- 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
|