@agentsdock/server 1.0.9 → 1.0.10-beta.1
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/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +1060 -2087
- package/server/codex_history_repair.py +53 -8
- package/server/codex_provider.py +52 -2
- package/server/codex_response_stream.py +229 -0
- package/server/install.sh +3 -3
|
@@ -659,16 +659,36 @@ class _AsyncDeliveryIndex:
|
|
|
659
659
|
starts: dict = field(default_factory=dict)
|
|
660
660
|
ends: dict = field(default_factory=dict)
|
|
661
661
|
receipts: dict = field(default_factory=dict)
|
|
662
|
+
status_queues: dict = field(default_factory=dict)
|
|
663
|
+
status_body_keys: set = field(default_factory=set)
|
|
662
664
|
steered: set = field(default_factory=set)
|
|
663
665
|
count: int = 0
|
|
664
666
|
|
|
665
667
|
def observe(self, event: dict) -> None:
|
|
666
668
|
event_type = event.get("type")
|
|
667
|
-
if (event_type not in {"turn_started", "turn_finished", "turn_steered", "turn_stopped", "turn_queue_run_now",
|
|
669
|
+
if (event_type not in {"turn_queued", "turn_started", "turn_finished", "turn_steered", "turn_stopped", "turn_queue_run_now",
|
|
668
670
|
"chat_conversation_message_received", "chat_conversation_message_started", "chat_conversation_message_delivered"}
|
|
669
671
|
or event.get("imported") is True or event.get("forked") is True
|
|
670
672
|
or event.get("backend") not in (None, "codex")):
|
|
671
673
|
return
|
|
674
|
+
if event_type == "turn_queued":
|
|
675
|
+
# The private durable request is exact provider input. Public display
|
|
676
|
+
# labels and wrapper-looking text are never evidence by themselves.
|
|
677
|
+
queued_id, body = event.get("queued_id"), event.get("request_prompt")
|
|
678
|
+
if (event.get("purpose") == "cross_chat_handoff_delivery"
|
|
679
|
+
and event.get("cross_chat_exchange_status") is True
|
|
680
|
+
and not _runtime_human_provenance(event)
|
|
681
|
+
and isinstance(queued_id, str) and 0 < len(queued_id) <= 256
|
|
682
|
+
and isinstance(body, str) and 0 < len(body) <= NATIVE_PROOF_LINE_BYTES):
|
|
683
|
+
record = {key: event.get(key) for key in ("seq", "cross_chat_exchange_id",
|
|
684
|
+
"cross_chat_exchange_leg_id", "source_session_id", "target_session_id")}
|
|
685
|
+
record["body_key"] = _text_key(body)
|
|
686
|
+
self.status_queues.setdefault(queued_id, []).append(record)
|
|
687
|
+
self.status_body_keys.add(record["body_key"])
|
|
688
|
+
self.count += 1
|
|
689
|
+
if self.count > MAX_KEYS:
|
|
690
|
+
raise _Unproven()
|
|
691
|
+
return
|
|
672
692
|
run = (event.get("interrupted_run_id") or event.get("run_id")) if event_type == "turn_queue_run_now" else event.get("run_id")
|
|
673
693
|
if (event_type in {"turn_steered", "turn_stopped"} or event.get("native_steer") is True):
|
|
674
694
|
if isinstance(run, str) and 0 < len(run) <= 256 and not run.startswith("import_"):
|
|
@@ -686,7 +706,8 @@ class _AsyncDeliveryIndex:
|
|
|
686
706
|
"provider_thread_id", "provider_turn_id", "cross_chat_envelope_id", "handoff_id", "message_id",
|
|
687
707
|
"source_session_id", "target_session_id", "source_title", "target_run_id", "conversation_mode",
|
|
688
708
|
"kind", "action", "handoff_action", "handoff_body_chars", "handoff_body_sha256",
|
|
689
|
-
"message_revision", "message_edited_by_user", "exchange_id", "cross_chat_exchange_id"
|
|
709
|
+
"message_revision", "message_edited_by_user", "exchange_id", "cross_chat_exchange_id",
|
|
710
|
+
"cross_chat_exchange_leg_id", "cross_chat_exchange_status", "queued_id")
|
|
690
711
|
record = {key: event.get(key) for key in fields
|
|
691
712
|
if event.get(key) is None or type(event.get(key)) in (int, bool)
|
|
692
713
|
or isinstance(event.get(key), str) and len(event[key]) <= 512}
|
|
@@ -710,11 +731,36 @@ class _AsyncDeliveryIndex:
|
|
|
710
731
|
raise _Unproven()
|
|
711
732
|
|
|
712
733
|
def match(self, session_id: str, provider_id: str, run: str, origin: dict,
|
|
713
|
-
body: tuple[str, str, int] | None, source_ids: set) -> dict | None:
|
|
734
|
+
body: tuple[str, str, int] | None, source_ids: set, body_key: str = "") -> dict | None:
|
|
714
735
|
starts, ends = self.starts.get(run, []), self.ends.get(run, [])
|
|
715
|
-
if
|
|
736
|
+
if len(starts) != 1 or len(ends) != 1 or run in self.steered:
|
|
716
737
|
return None
|
|
717
738
|
start, end = starts[0], ends[0]
|
|
739
|
+
if start.get("cross_chat_exchange_status") is True:
|
|
740
|
+
queued = self.status_queues.get(start.get("queued_id"), [])
|
|
741
|
+
if len(queued) != 1:
|
|
742
|
+
return None
|
|
743
|
+
receipt = queued[0]
|
|
744
|
+
identity = ("cross_chat_exchange_id", "cross_chat_exchange_leg_id",
|
|
745
|
+
"source_session_id", "target_session_id")
|
|
746
|
+
if (start.get("malformed") or end.get("malformed") or not start.get("body_key")
|
|
747
|
+
or any(not isinstance(start.get(key), str) or not start[key]
|
|
748
|
+
or receipt.get(key) != start[key] or end.get(key) != start[key] for key in identity)
|
|
749
|
+
or start.get("target_session_id") != session_id or start.get("source_session_id") == session_id
|
|
750
|
+
or start.get("backend") != "codex" or end.get("backend") != "codex"
|
|
751
|
+
or end.get("cross_chat_exchange_status") is not True or end.get("transport") != "app-server"
|
|
752
|
+
or end.get("provider_thread_id") != provider_id or end.get("provider_turn_id") != origin.get("turn_id")
|
|
753
|
+
or receipt["body_key"] != body_key or source_ids != {origin.get("event_id")} or not origin.get("event_id")
|
|
754
|
+
or any(type(record.get("seq")) is not int for record in (receipt, start, end))
|
|
755
|
+
or not receipt["seq"] < start["seq"] < end["seq"]
|
|
756
|
+
or not isinstance(start.get("id"), str) or not start["id"]):
|
|
757
|
+
return None
|
|
758
|
+
times = [_delivery_timestamp(value) for value in (start.get("ts"), origin.get("timestamp"), end.get("ts"))]
|
|
759
|
+
if any(value is None for value in times) or not times[0] <= times[1] <= times[2]:
|
|
760
|
+
return None
|
|
761
|
+
return _native_event_identity(start)
|
|
762
|
+
if body is None:
|
|
763
|
+
return None
|
|
718
764
|
envelope = start.get("cross_chat_envelope_id")
|
|
719
765
|
if (start.get("malformed") or end.get("malformed")
|
|
720
766
|
or not isinstance(envelope, str) or not envelope or start.get("backend") != "codex"
|
|
@@ -1044,7 +1090,7 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
|
|
|
1044
1090
|
native_matches = native.get((native_run, kind, body_key), [])
|
|
1045
1091
|
if not native_matches and kind == "user":
|
|
1046
1092
|
delivery = deliveries.match(session_id, thread, native_run, source_origin,
|
|
1047
|
-
delivery_bodies.get(key), delivery_source_ids.get(key[0], set()))
|
|
1093
|
+
delivery_bodies.get(key), delivery_source_ids.get(key[0], set()), body_key)
|
|
1048
1094
|
if delivery is not None:
|
|
1049
1095
|
native_matches = [delivery]
|
|
1050
1096
|
if not native_matches and kind == "assistant":
|
|
@@ -1155,7 +1201,7 @@ def _prove_pending_async_deliveries(session_id: str, provider_id: str, items: li
|
|
|
1155
1201
|
if (item.get("kind") != "user" or not isinstance(item.get("text"), str)
|
|
1156
1202
|
or not isinstance(origin, dict) or origin.get("provider") != "codex" or origin.get("kind") != "user"
|
|
1157
1203
|
or origin.get("session_id", provider_id) != provider_id or item.get("source_text_sha256") is not None
|
|
1158
|
-
or not _async_delivery_body(item["text"])):
|
|
1204
|
+
or not (_async_delivery_body(item["text"]) or _text_key(item["text"]) in deliveries.status_body_keys)):
|
|
1159
1205
|
continue
|
|
1160
1206
|
candidate = _replay_target({"seq": previous_seq + index + 2, "id": f"pending:{index}", "run_id": run,
|
|
1161
1207
|
"type": "turn_started", "backend": "codex", "imported": True, "prompt": item["text"],
|
|
@@ -1205,8 +1251,7 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1205
1251
|
deliveries = _AsyncDeliveryIndex()
|
|
1206
1252
|
prove_deliveries = (source_path is not None and root is not None
|
|
1207
1253
|
and isinstance(sync_checkpoint, dict) and callable(parse_item)
|
|
1208
|
-
and any(item.get("kind") == "user" and isinstance(item.get("text"), str)
|
|
1209
|
-
and _async_delivery_body(item["text"]) for item in items))
|
|
1254
|
+
and any(item.get("kind") == "user" and isinstance(item.get("text"), str) for item in items))
|
|
1210
1255
|
previous_seq, owner_count = 0, 0
|
|
1211
1256
|
for event, _offset, _line in _native_records(events, stamp, budget):
|
|
1212
1257
|
seq = event.get("seq")
|
package/server/codex_provider.py
CHANGED
|
@@ -195,6 +195,19 @@ def session_choice(value) -> str:
|
|
|
195
195
|
return choice
|
|
196
196
|
|
|
197
197
|
|
|
198
|
+
RUNTIME_FIELDS = ("codex_provider", "codex_provider_revision", "codex_provider_binding", "model", "effort", "_codex_provider_removed")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def requested_session(session: dict) -> dict:
|
|
202
|
+
"""Desired runtime is separate from the owner of an in-flight turn."""
|
|
203
|
+
pending = session.get("_codex_provider_pending")
|
|
204
|
+
return {**session, **pending} if isinstance(pending, dict) else dict(session)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def runtime_selection(session: dict) -> dict:
|
|
208
|
+
return {key: session.get(key) for key in RUNTIME_FIELDS}
|
|
209
|
+
|
|
210
|
+
|
|
198
211
|
class ProviderStore:
|
|
199
212
|
"""Atomic metadata pointer plus private, endpoint-bound credential records."""
|
|
200
213
|
def __init__(self, root: Path):
|
|
@@ -329,6 +342,8 @@ class ProviderStore:
|
|
|
329
342
|
def for_session(self, session: dict, *, include_key=False) -> dict | None:
|
|
330
343
|
if session_choice(session.get("codex_provider")) == "default":
|
|
331
344
|
return None
|
|
345
|
+
if session.get("_codex_provider_removed"):
|
|
346
|
+
raise HTTPException(409, "Configure the custom Codex endpoint before using this chat.")
|
|
332
347
|
revision = session.get("codex_provider_revision")
|
|
333
348
|
selected = self.selection(include_key=include_key, revision=revision, include_revision=True)
|
|
334
349
|
if selected is None:
|
|
@@ -345,6 +360,30 @@ class ProviderStore:
|
|
|
345
360
|
selected["model"] = validate_model(model)
|
|
346
361
|
return selected
|
|
347
362
|
|
|
363
|
+
def control(self, session: dict, *, available=True, summary=False) -> dict:
|
|
364
|
+
requested = requested_session(session)
|
|
365
|
+
pending = isinstance(session.get("_codex_provider_pending"), dict)
|
|
366
|
+
requested_provider = session_choice(requested.get("codex_provider"))
|
|
367
|
+
def endpoint(value):
|
|
368
|
+
if session_choice(value.get("codex_provider")) != "custom" or value.get("_codex_provider_removed"):
|
|
369
|
+
return None
|
|
370
|
+
try:
|
|
371
|
+
selected = self._public_selections.get(value.get("codex_provider_revision")) or self.for_session(value)
|
|
372
|
+
return selected.get("base_url") if selected else None
|
|
373
|
+
except HTTPException:
|
|
374
|
+
return None
|
|
375
|
+
return {"pending": pending,
|
|
376
|
+
"pending_reason": session.get("_codex_provider_pending_reason") if pending else None,
|
|
377
|
+
"requested_provider": requested_provider,
|
|
378
|
+
"active_provider": session_choice(session.get("codex_provider")),
|
|
379
|
+
"requested_model": requested.get("model"), "requested_effort": requested.get("effort"),
|
|
380
|
+
# The next-turn picker must use the requested endpoint's catalog,
|
|
381
|
+
# while active runtime fields continue describing running work.
|
|
382
|
+
# Avoid duplicating the active catalog on ordinary session events.
|
|
383
|
+
"requested_catalog": self.catalog(available=available, session=requested, summary=summary)
|
|
384
|
+
if pending and requested_provider == "custom" else None,
|
|
385
|
+
"requested_base_url": endpoint(requested), "active_base_url": endpoint(session)}
|
|
386
|
+
|
|
348
387
|
def registration(self, *, include_key=False) -> dict | None:
|
|
349
388
|
# A damaged optional endpoint must not prevent normal Codex startup.
|
|
350
389
|
# Custom chat admission and the admin status route still fail closed.
|
|
@@ -575,6 +614,17 @@ class ProviderStore:
|
|
|
575
614
|
if selected:
|
|
576
615
|
self._atomic(self._binding_name(thread_id), {"binding": binding(selected),
|
|
577
616
|
"credential_id": selected.get("credential_id")})
|
|
617
|
+
else:
|
|
618
|
+
self._atomic(self._binding_name(thread_id), {"binding": None, "credential_id": None})
|
|
619
|
+
|
|
620
|
+
def thread_binding(self, thread_id: str) -> dict:
|
|
621
|
+
"""Snapshot rollback metadata without reopening revoked credentials."""
|
|
622
|
+
with self.lock:
|
|
623
|
+
return self._read(self._binding_name(thread_id)) or {"binding": None, "credential_id": None}
|
|
624
|
+
|
|
625
|
+
def restore_thread_binding(self, thread_id: str, record: dict) -> None:
|
|
626
|
+
with self.lock:
|
|
627
|
+
self._atomic(self._binding_name(thread_id), record)
|
|
578
628
|
|
|
579
629
|
def for_thread(self, thread_id: str, *, include_key=False) -> dict | None:
|
|
580
630
|
with self.lock:
|
|
@@ -1076,7 +1126,7 @@ def create_router(*, authorize, store: ProviderStore, mutate, probe, available,
|
|
|
1076
1126
|
session = session_lookup(session_id) if session_lookup else None
|
|
1077
1127
|
if not session:
|
|
1078
1128
|
raise HTTPException(404, "Chat not found.")
|
|
1079
|
-
selected = await asyncio.to_thread(store.for_session, session, include_key=True)
|
|
1129
|
+
selected = await asyncio.to_thread(store.for_session, requested_session(session), include_key=True)
|
|
1080
1130
|
else:
|
|
1081
1131
|
selected = await asyncio.to_thread(store.selection, include_key=True, include_revision=True)
|
|
1082
1132
|
if selected is None:
|
|
@@ -1141,7 +1191,7 @@ def create_router(*, authorize, store: ProviderStore, mutate, probe, available,
|
|
|
1141
1191
|
session = session_lookup(session_id) if session_lookup else None
|
|
1142
1192
|
if not session:
|
|
1143
1193
|
raise HTTPException(404, "Chat not found.")
|
|
1144
|
-
selected = await asyncio.to_thread(store.for_session, session, include_key=True)
|
|
1194
|
+
selected = await asyncio.to_thread(store.for_session, requested_session(session), include_key=True)
|
|
1145
1195
|
else:
|
|
1146
1196
|
selected = await asyncio.to_thread(store.selection, include_key=True, include_revision=True)
|
|
1147
1197
|
if selected is None:
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Keep nonstandard custom Responses errors visible to native Codex.
|
|
2
|
+
|
|
3
|
+
An owned loopback transport forwards requests unchanged to one immutable endpoint.
|
|
4
|
+
Only untyped SSE error packets are normalized; native Codex owns all retries.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from contextlib import contextmanager, suppress
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import secrets
|
|
13
|
+
import socket
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import uvicorn
|
|
18
|
+
from fastapi import FastAPI, Request
|
|
19
|
+
from fastapi.responses import Response, StreamingResponse
|
|
20
|
+
|
|
21
|
+
from codex_app_server import CodexAppServerManager
|
|
22
|
+
from codex_provider import ENV_KEY, PROVIDER_ID, config_args
|
|
23
|
+
|
|
24
|
+
# Larger frames pass through unchanged, rather than buffering arbitrary output.
|
|
25
|
+
# This is a parser memory bound, not a response or content size limit.
|
|
26
|
+
_ERROR_FRAME_BYTES = 64 * 1024
|
|
27
|
+
_HOP_HEADERS = {'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
28
|
+
'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length'}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ErrorFrames:
|
|
32
|
+
def __init__(self, secret: str):
|
|
33
|
+
self.secret = secret
|
|
34
|
+
self.pending = bytearray()
|
|
35
|
+
self.passthrough = False
|
|
36
|
+
self.response_id = 'resp_custom_endpoint_error'
|
|
37
|
+
|
|
38
|
+
def frame(self, raw: bytes) -> bytes:
|
|
39
|
+
try:
|
|
40
|
+
data = b'\n'.join(line[5:].lstrip(b' ') for line in raw.splitlines() if line.startswith(b'data:'))
|
|
41
|
+
packet = json.loads(data)
|
|
42
|
+
except (ValueError, UnicodeError):
|
|
43
|
+
return raw
|
|
44
|
+
if not isinstance(packet, dict):
|
|
45
|
+
return raw
|
|
46
|
+
response = packet.get('response')
|
|
47
|
+
if isinstance(response, dict) and isinstance(response.get('id'), str):
|
|
48
|
+
self.response_id = response['id']
|
|
49
|
+
error = packet.get('error')
|
|
50
|
+
if packet.get('type') or not error:
|
|
51
|
+
return raw
|
|
52
|
+
if isinstance(error, dict):
|
|
53
|
+
message = error.get('message')
|
|
54
|
+
if not isinstance(message, str) or not message:
|
|
55
|
+
return raw
|
|
56
|
+
code = str(error.get('code') or error.get('type') or 'server_error')
|
|
57
|
+
elif isinstance(error, str):
|
|
58
|
+
message, code = error, 'server_error'
|
|
59
|
+
else:
|
|
60
|
+
return raw
|
|
61
|
+
code = {'429': 'rate_limit_exceeded', '401': 'invalid_api_key',
|
|
62
|
+
'403': 'permission_denied', '400': 'invalid_request_error',
|
|
63
|
+
'500': 'server_error', '502': 'server_error', '503': 'server_error'}.get(code, code)
|
|
64
|
+
for value in (self.secret, quote(self.secret, safe='')):
|
|
65
|
+
if value:
|
|
66
|
+
message = message.replace(value, '[redacted]')
|
|
67
|
+
failed = {'type': 'response.failed', 'response': {
|
|
68
|
+
'id': self.response_id, 'object': 'response', 'created_at': 0,
|
|
69
|
+
'status': 'failed', 'output': [], 'error': {'code': code, 'message': message}}}
|
|
70
|
+
return b'event: response.failed\ndata: ' + json.dumps(failed, ensure_ascii=False).encode() + b'\n\n'
|
|
71
|
+
|
|
72
|
+
async def stream(self, chunks):
|
|
73
|
+
# Split at SSE blank lines, including CRLF boundaries across chunks.
|
|
74
|
+
async for chunk in chunks:
|
|
75
|
+
self.pending.extend(chunk)
|
|
76
|
+
while self.pending:
|
|
77
|
+
lf = self.pending.find(b'\n\n')
|
|
78
|
+
crlf = self.pending.find(b'\r\n\r\n')
|
|
79
|
+
endings = [(i, n) for i, n in ((lf, 2), (crlf, 4)) if i >= 0]
|
|
80
|
+
if endings:
|
|
81
|
+
i, n = min(endings)
|
|
82
|
+
raw = bytes(self.pending[:i+n]); del self.pending[:i+n]
|
|
83
|
+
yield raw if self.passthrough or len(raw) > _ERROR_FRAME_BYTES else self.frame(raw)
|
|
84
|
+
self.passthrough = False
|
|
85
|
+
elif len(self.pending) > _ERROR_FRAME_BYTES:
|
|
86
|
+
# Retain only a delimiter tail; do not truncate a large frame.
|
|
87
|
+
yield bytes(self.pending[:-3]); del self.pending[:-3]
|
|
88
|
+
self.passthrough = True
|
|
89
|
+
else:
|
|
90
|
+
break
|
|
91
|
+
if self.pending:
|
|
92
|
+
raw = bytes(self.pending); self.pending.clear()
|
|
93
|
+
yield raw if self.passthrough else self.frame(raw)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _OwnedConfig(uvicorn.Config):
|
|
97
|
+
def configure_logging(self):
|
|
98
|
+
# Do not change the host server's process-wide logging configuration.
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _OwnedServer(uvicorn.Server):
|
|
103
|
+
@contextmanager
|
|
104
|
+
def capture_signals(self):
|
|
105
|
+
# This transport must never take over its host server's signal handlers.
|
|
106
|
+
yield
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ResponseStreamBridge:
|
|
110
|
+
def __init__(self, selected: dict):
|
|
111
|
+
self.upstream = selected['base_url'].rstrip('/')
|
|
112
|
+
self.key = selected['api_key']
|
|
113
|
+
self.token = secrets.token_urlsafe(32)
|
|
114
|
+
self.url = ''
|
|
115
|
+
self.task = self.server = self.client = self.socket = None
|
|
116
|
+
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
|
117
|
+
app.add_api_route('/v1/{path:path}', self.forward, methods=['POST', 'GET', 'DELETE'])
|
|
118
|
+
self.app = app
|
|
119
|
+
|
|
120
|
+
async def start(self):
|
|
121
|
+
if self.task is not None and not self.task.done():
|
|
122
|
+
return
|
|
123
|
+
self.client = httpx.AsyncClient(timeout=None, follow_redirects=False, trust_env=False)
|
|
124
|
+
self.socket = socket.socket()
|
|
125
|
+
self.socket.bind(('127.0.0.1', 0)); self.socket.listen(128); self.socket.setblocking(False)
|
|
126
|
+
self.url = f'http://127.0.0.1:{self.socket.getsockname()[1]}/v1'
|
|
127
|
+
self.server = _OwnedServer(_OwnedConfig(self.app, log_config=None, access_log=False, lifespan='off', log_level='critical'))
|
|
128
|
+
self.task = asyncio.create_task(self.server.serve(sockets=[self.socket]))
|
|
129
|
+
try:
|
|
130
|
+
while not self.server.started:
|
|
131
|
+
if self.task.done():
|
|
132
|
+
await self.task
|
|
133
|
+
raise RuntimeError('Custom response transport could not start')
|
|
134
|
+
await asyncio.sleep(0)
|
|
135
|
+
except BaseException:
|
|
136
|
+
await self.close()
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
async def forward(self, request: Request, path: str):
|
|
140
|
+
if not hmac.compare_digest(request.headers.get('authorization', ''), 'Bearer ' + self.token):
|
|
141
|
+
return Response(status_code=401)
|
|
142
|
+
# The destination is immutable. A request cannot name a different origin.
|
|
143
|
+
if not (path == 'responses' or path.startswith('responses/')) or '..' in path.split('/'):
|
|
144
|
+
return Response(status_code=404)
|
|
145
|
+
headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP_HEADERS | {'authorization', 'accept-encoding', 'cookie'}}
|
|
146
|
+
headers.update(authorization='Bearer ' + self.key, **{'accept-encoding': 'identity'})
|
|
147
|
+
if 'content-length' in request.headers:
|
|
148
|
+
headers['content-length'] = request.headers['content-length']
|
|
149
|
+
uploaded = asyncio.Event()
|
|
150
|
+
async def upload():
|
|
151
|
+
try:
|
|
152
|
+
async for chunk in request.stream(): yield chunk
|
|
153
|
+
finally:
|
|
154
|
+
uploaded.set()
|
|
155
|
+
async def disconnected():
|
|
156
|
+
# Wait for the upload reader to finish before reading disconnects.
|
|
157
|
+
# No polling, execution deadline, or competing request-body reader.
|
|
158
|
+
await uploaded.wait()
|
|
159
|
+
while (await request.receive()).get('type') != 'http.disconnect':
|
|
160
|
+
pass
|
|
161
|
+
req = self.client.build_request(request.method, self.upstream + '/' + path,
|
|
162
|
+
params=request.query_params.multi_items(), headers=headers, content=upload())
|
|
163
|
+
sending = asyncio.create_task(self.client.send(req, stream=True))
|
|
164
|
+
disconnect = asyncio.create_task(disconnected())
|
|
165
|
+
try:
|
|
166
|
+
done, _ = await asyncio.wait((sending, disconnect), return_when=asyncio.FIRST_COMPLETED)
|
|
167
|
+
if disconnect in done:
|
|
168
|
+
if sending.done() and not sending.cancelled() and sending.exception() is None:
|
|
169
|
+
await sending.result().aclose()
|
|
170
|
+
return Response(status_code=499)
|
|
171
|
+
upstream = sending.result()
|
|
172
|
+
except httpx.HTTPError:
|
|
173
|
+
return Response('Custom endpoint connection failed', status_code=502)
|
|
174
|
+
finally:
|
|
175
|
+
for task in (sending, disconnect):
|
|
176
|
+
if not task.done(): task.cancel()
|
|
177
|
+
await asyncio.gather(sending, disconnect, return_exceptions=True)
|
|
178
|
+
headers = {k: v for k, v in upstream.headers.items() if k.lower() not in _HOP_HEADERS | {'content-encoding', 'set-cookie'}}
|
|
179
|
+
async def body():
|
|
180
|
+
try:
|
|
181
|
+
chunks = upstream.aiter_bytes()
|
|
182
|
+
if upstream.status_code == 200 and 'text/event-stream' in upstream.headers.get('content-type', '').lower():
|
|
183
|
+
chunks = ErrorFrames(self.key).stream(chunks)
|
|
184
|
+
async for chunk in chunks:
|
|
185
|
+
yield chunk
|
|
186
|
+
finally:
|
|
187
|
+
await upstream.aclose()
|
|
188
|
+
return StreamingResponse(body(), status_code=upstream.status_code, headers=headers)
|
|
189
|
+
|
|
190
|
+
async def close(self):
|
|
191
|
+
if self.server is not None:
|
|
192
|
+
self.server.should_exit = True
|
|
193
|
+
# The owning native process has already closed; cancel only this
|
|
194
|
+
# listener's in-flight transports, including a silent upstream.
|
|
195
|
+
tasks = tuple(self.server.server_state.tasks)
|
|
196
|
+
for task in tasks: task.cancel()
|
|
197
|
+
if tasks: await asyncio.gather(*tasks, return_exceptions=True)
|
|
198
|
+
if self.task is not None:
|
|
199
|
+
# Let Uvicorn close its asyncio listeners and remove their readers.
|
|
200
|
+
# Cancelling serve() skips shutdown and can leave a stale selector
|
|
201
|
+
# entry when the next provider reuses the same socket descriptor.
|
|
202
|
+
with suppress(asyncio.CancelledError): await self.task
|
|
203
|
+
if self.client is not None: await self.client.aclose()
|
|
204
|
+
if self.socket is not None: self.socket.close()
|
|
205
|
+
self.task = self.server = self.client = self.socket = None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class CustomCodexAppServerManager(CodexAppServerManager):
|
|
209
|
+
"""Native manager with an owned, credential-scoped stream normalizer."""
|
|
210
|
+
def __init__(self, *args, selected: dict, **kwargs):
|
|
211
|
+
self.response_bridge = ResponseStreamBridge(selected)
|
|
212
|
+
prepare = kwargs.pop('before_start', None)
|
|
213
|
+
environment = kwargs.pop('env_factory')
|
|
214
|
+
original_args = tuple(kwargs.get('app_server_args', ()))
|
|
215
|
+
async def before_start():
|
|
216
|
+
if prepare is not None: await prepare()
|
|
217
|
+
await self.response_bridge.start()
|
|
218
|
+
self.client.app_server_args = (*original_args, *config_args({
|
|
219
|
+
f'model_providers.{PROVIDER_ID}.base_url': self.response_bridge.url}))
|
|
220
|
+
def env_factory():
|
|
221
|
+
return {**environment(), ENV_KEY: self.response_bridge.token}
|
|
222
|
+
kwargs['sensitive_values'] = (*kwargs.get('sensitive_values', ()), self.response_bridge.token)
|
|
223
|
+
super().__init__(*args, before_start=before_start, env_factory=env_factory, **kwargs)
|
|
224
|
+
|
|
225
|
+
async def close(self):
|
|
226
|
+
try:
|
|
227
|
+
await super().close()
|
|
228
|
+
finally:
|
|
229
|
+
await self.response_bridge.close()
|
package/server/install.sh
CHANGED
|
@@ -1839,7 +1839,7 @@ if [[ "$TEAM_HUB_MODE" == "host" && "$TEAM_HUB_OPERATION_PENDING" != "true" \
|
|
|
1839
1839
|
fi
|
|
1840
1840
|
fi
|
|
1841
1841
|
|
|
1842
|
-
RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py codex_side_chat_progress.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py cursor_process_guard.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
|
|
1842
|
+
RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py codex_response_stream.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py codex_side_chat_progress.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py cursor_process_guard.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
|
|
1843
1843
|
RELEASE_DIRECTORIES=(agentsdock_team_hub)
|
|
1844
1844
|
TEAM_HUB_RELEASE_FILES=(
|
|
1845
1845
|
__init__.py
|
|
@@ -3751,7 +3751,7 @@ validate_staged_release_runtime() (
|
|
|
3751
3751
|
"$STAGE_DIR/claude_background_reconciliation.py" \
|
|
3752
3752
|
"$STAGE_DIR/codex_app_server.py" \
|
|
3753
3753
|
"$STAGE_DIR/codex_auth.py" \
|
|
3754
|
-
"$STAGE_DIR/codex_provider.py" \
|
|
3754
|
+
"$STAGE_DIR/codex_provider.py" "$STAGE_DIR/codex_response_stream.py" \
|
|
3755
3755
|
"$STAGE_DIR/provider_connections.py" "$STAGE_DIR/cursor_api_key.py" \
|
|
3756
3756
|
"$STAGE_DIR/side_questions.py" \
|
|
3757
3757
|
"$STAGE_DIR/workspace_git.py" \
|
|
@@ -3781,7 +3781,7 @@ validate_staged_release_runtime() (
|
|
|
3781
3781
|
"$STAGE_DIR/update_runner.py"
|
|
3782
3782
|
"$STAGE_DIR/.venv/bin/python" -m py_compile "$STAGE_DIR/server_instances.py"
|
|
3783
3783
|
"$STAGE_DIR/.venv/bin/python" -m compileall -q "$STAGE_DIR/agentsdock_team_hub"
|
|
3784
|
-
PYTHONPATH="$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c 'import execution_activation, execution_preparation, update_preparation, update_handoff, update_recovery, execution_update_status, execution_uninstall, execution_recovery, execution_recovery_status, execution_http, execution_durability, execution_control, execution_install, execution_maintenance, execution_manage, execution_ownership, execution_service, execution_transport; import workspace_git, claude_model_catalog, server_instances, local_session_ownership, cursor_history; import codex_auth, codex_provider, provider_connections, cursor_api_key, side_questions, title_generation, codex_side_question, codex_side_chat_progress, claude_side_question, agentsdock_team_hub, cursor_agent_client, cursor_provider_mcp, opencode_agent_client, cursor_process_guard, secure_peer_delivery, secure_peer_runtime, team_mail_runtime, team_mail_websocket, team_mail_grants, team_hub_host, agentsdock_mail, agentsdock_team, claude_history_repair, claude_history_provenance, claude_goals, claude_background_reconciliation, codex_history_repair, public_chat_shares, public_chat_transcript, public_chat_share_routes, interactive_chat_shares, interactive_chat_share_routes, interactive_chat_share_web, interactive_chat_projection, interactive_chat_runtime, interactive_chat_native, shared_chat_videos, shared_chat_video_stream, interactive_chat_controls, chat_mailbox, provider_commands, provider_usage; from agentsdock_team_hub import secure_peer, secure_peer_hub' >/dev/null
|
|
3784
|
+
PYTHONPATH="$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c 'import execution_activation, execution_preparation, update_preparation, update_handoff, update_recovery, execution_update_status, execution_uninstall, execution_recovery, execution_recovery_status, execution_http, execution_durability, execution_control, execution_install, execution_maintenance, execution_manage, execution_ownership, execution_service, execution_transport; import workspace_git, claude_model_catalog, server_instances, local_session_ownership, cursor_history; import codex_auth, codex_provider, codex_response_stream, provider_connections, cursor_api_key, side_questions, title_generation, codex_side_question, codex_side_chat_progress, claude_side_question, agentsdock_team_hub, cursor_agent_client, cursor_provider_mcp, opencode_agent_client, cursor_process_guard, secure_peer_delivery, secure_peer_runtime, team_mail_runtime, team_mail_websocket, team_mail_grants, team_hub_host, agentsdock_mail, agentsdock_team, claude_history_repair, claude_history_provenance, claude_goals, claude_background_reconciliation, codex_history_repair, public_chat_shares, public_chat_transcript, public_chat_share_routes, interactive_chat_shares, interactive_chat_share_routes, interactive_chat_share_web, interactive_chat_projection, interactive_chat_runtime, interactive_chat_native, shared_chat_videos, shared_chat_video_stream, interactive_chat_controls, chat_mailbox, provider_commands, provider_usage; from agentsdock_team_hub import secure_peer, secure_peer_hub' >/dev/null
|
|
3785
3785
|
)
|
|
3786
3786
|
|
|
3787
3787
|
abort_unclaimed_team_hub_reactivation() {
|