@agentsdock/server 1.0.8 → 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 +1112 -2102
- 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/codex_side_chat_progress.py +110 -0
- package/server/codex_side_question.py +45 -5
- package/server/install.sh +121 -8
- package/server/instances.sh +0 -0
- package/server/side_questions.py +128 -31
|
@@ -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()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""User-visible native Codex side-thread activity; never project parent/control input."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CodexSideChatProgress:
|
|
9
|
+
def __init__(self, publish, *, thread_id):
|
|
10
|
+
self.publish = publish
|
|
11
|
+
self.thread_id = thread_id
|
|
12
|
+
self.items = {}
|
|
13
|
+
self.parts = {}
|
|
14
|
+
self.completed = set()
|
|
15
|
+
self.pending = None
|
|
16
|
+
self.dirty = False
|
|
17
|
+
|
|
18
|
+
def _item(self, identifier, kind):
|
|
19
|
+
key = f"{identifier}:{kind}"
|
|
20
|
+
if key not in self.items:
|
|
21
|
+
self.items[key] = {"id": key, "kind": kind, "text": "", "status": "running"}
|
|
22
|
+
return self.items[key]
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def _text(parts):
|
|
26
|
+
if not isinstance(parts, list):
|
|
27
|
+
return ""
|
|
28
|
+
return "\n\n".join(part if isinstance(part, str) else str(part.get("text") or part.get("summary_text") or "")
|
|
29
|
+
for part in parts if isinstance(part, (str, dict)))
|
|
30
|
+
|
|
31
|
+
def receive(self, packet):
|
|
32
|
+
method, data = packet.get("method"), packet.get("params") or {}
|
|
33
|
+
# The transport normally scopes notifications; enforce it here too so
|
|
34
|
+
# descendant activity cannot become the side assistant's own answer.
|
|
35
|
+
if data.get("threadId") not in (None, self.thread_id):
|
|
36
|
+
return
|
|
37
|
+
item = data.get("item") or {}
|
|
38
|
+
identifier = str(data.get("itemId") or item.get("id") or "")
|
|
39
|
+
if not identifier or identifier in self.completed:
|
|
40
|
+
return
|
|
41
|
+
changed = False
|
|
42
|
+
if method in {"item/reasoning/summaryTextDelta", "item/reasoning/textDelta", "item/reasoning/summaryPartAdded"}:
|
|
43
|
+
kind = "reasoning" if method == "item/reasoning/textDelta" else "reasoning_summary"
|
|
44
|
+
field = "contentIndex" if kind == "reasoning" else "summaryIndex"
|
|
45
|
+
index = data.get(field, 0)
|
|
46
|
+
if not isinstance(index, int) or index < 0:
|
|
47
|
+
return
|
|
48
|
+
parts = self.parts.setdefault((identifier, kind), {})
|
|
49
|
+
parts[index] = parts.get(index, "") + str(data.get("delta") or "")
|
|
50
|
+
self._item(identifier, kind)["text"] = "\n\n".join(parts[i] for i in sorted(parts))
|
|
51
|
+
changed = True
|
|
52
|
+
elif method == "item/agentMessage/delta":
|
|
53
|
+
# item/started carries the phase. Older runtimes may emit deltas
|
|
54
|
+
# first; item/completed supplies the authoritative final phase.
|
|
55
|
+
kind = "commentary" if f"{identifier}:commentary" in self.items else "answer"
|
|
56
|
+
self._item(identifier, kind)["text"] += str(data.get("delta") or "")
|
|
57
|
+
changed = True
|
|
58
|
+
elif method in {"item/started", "item/completed"}:
|
|
59
|
+
item_type = item.get("type")
|
|
60
|
+
if item_type == "reasoning":
|
|
61
|
+
for kind, field in (("reasoning_summary", "summary"), ("reasoning", "content")):
|
|
62
|
+
text = self._text(item.get(field))
|
|
63
|
+
if text or (identifier, kind) in self.parts:
|
|
64
|
+
projected = self._item(identifier, kind)
|
|
65
|
+
if text:
|
|
66
|
+
projected["text"] = text
|
|
67
|
+
changed = True
|
|
68
|
+
elif item_type == "agentMessage":
|
|
69
|
+
kind = "commentary" if item.get("phase") == "commentary" else "answer"
|
|
70
|
+
other = f"{identifier}:{'answer' if kind == 'commentary' else 'commentary'}"
|
|
71
|
+
if other in self.items:
|
|
72
|
+
# Keep its chronological slot when an older runtime omits phase at start.
|
|
73
|
+
self.items = {f"{identifier}:{kind}" if key == other else key:
|
|
74
|
+
{**value, "id": f"{identifier}:{kind}", "kind": kind} if key == other else value
|
|
75
|
+
for key, value in self.items.items()}
|
|
76
|
+
projected = self._item(identifier, kind)
|
|
77
|
+
if isinstance(item.get("text"), str) and (item["text"] or method == "item/completed"):
|
|
78
|
+
projected["text"] = item["text"]
|
|
79
|
+
changed = True
|
|
80
|
+
elif item_type in {"commandExecution", "mcpToolCall", "dynamicToolCall", "collabAgentToolCall",
|
|
81
|
+
"collabToolCall", "webSearch", "fileChange", "imageView", "imageGeneration"}:
|
|
82
|
+
projected = self._item(identifier, "tool")
|
|
83
|
+
projected["tool"] = item_type
|
|
84
|
+
text = item.get("command") if item_type == "commandExecution" else item.get("tool") or item.get("query") or item.get("path")
|
|
85
|
+
if not text and item_type == "fileChange":
|
|
86
|
+
text = ", ".join(str(change.get("path") or "") for change in item.get("changes", []) if isinstance(change, dict))
|
|
87
|
+
if text:
|
|
88
|
+
projected["text"] = str(text)
|
|
89
|
+
changed = True
|
|
90
|
+
if method == "item/completed":
|
|
91
|
+
self.completed.add(identifier)
|
|
92
|
+
for key, value in self.items.items():
|
|
93
|
+
if key.startswith(identifier + ":"):
|
|
94
|
+
value["status"] = "completed"
|
|
95
|
+
if changed:
|
|
96
|
+
self.dirty = True
|
|
97
|
+
if self.pending is None:
|
|
98
|
+
self.pending = asyncio.create_task(self._publish_later())
|
|
99
|
+
|
|
100
|
+
async def _publish_later(self):
|
|
101
|
+
# Coalesce token bursts; no per-token disk writes or client history reads.
|
|
102
|
+
await asyncio.sleep(0.1)
|
|
103
|
+
while self.dirty:
|
|
104
|
+
self.dirty = False
|
|
105
|
+
await self.publish(deepcopy(list(self.items.values())))
|
|
106
|
+
self.pending = None
|
|
107
|
+
|
|
108
|
+
async def flush(self):
|
|
109
|
+
if self.pending is not None:
|
|
110
|
+
await asyncio.shield(self.pending)
|
|
@@ -179,6 +179,7 @@ class NativeCodexSideChat:
|
|
|
179
179
|
self._cleaning: asyncio.Task | None = None
|
|
180
180
|
self._process_started = asyncio.Event()
|
|
181
181
|
self._active: asyncio.Task | None = None
|
|
182
|
+
self._progress = None
|
|
182
183
|
self._lock = asyncio.Lock()
|
|
183
184
|
self._closed = False
|
|
184
185
|
|
|
@@ -186,6 +187,12 @@ class NativeCodexSideChat:
|
|
|
186
187
|
def closed(self) -> bool:
|
|
187
188
|
return self._closed
|
|
188
189
|
|
|
190
|
+
def snapshot_progress(self, task):
|
|
191
|
+
"""Copy exposed native items at Stop without flushing or awaiting IO."""
|
|
192
|
+
if task is None or task.done() or self._active is not task or self._progress is None:
|
|
193
|
+
return None
|
|
194
|
+
return deepcopy(list(self._progress.items.values()))
|
|
195
|
+
|
|
189
196
|
async def _handle_server_request(self, request_id, method, params):
|
|
190
197
|
# The callback can present approvals through the existing UI, but only
|
|
191
198
|
# for this exact child. Never route a parent's inherited tool request.
|
|
@@ -284,7 +291,7 @@ class NativeCodexSideChat:
|
|
|
284
291
|
elif metadata.get("ephemeral") is not True or metadata.get("path") is not None:
|
|
285
292
|
raise SideQuestionError(503, "Codex did not confirm an ephemeral side chat")
|
|
286
293
|
|
|
287
|
-
async def ask(self, question: str) -> str:
|
|
294
|
+
async def ask(self, question: str, *, runtime_settings: dict | None = None, on_progress=None) -> str:
|
|
288
295
|
if self._closed:
|
|
289
296
|
raise SideQuestionError(409, "Side chat was closed; open a new side chat")
|
|
290
297
|
if self._lock.locked():
|
|
@@ -292,6 +299,7 @@ class NativeCodexSideChat:
|
|
|
292
299
|
async with self._lock:
|
|
293
300
|
self._active = asyncio.current_task()
|
|
294
301
|
turn = None
|
|
302
|
+
progress = None
|
|
295
303
|
try:
|
|
296
304
|
if self._opening is None:
|
|
297
305
|
self._opening = asyncio.create_task(self._open())
|
|
@@ -300,14 +308,37 @@ class NativeCodexSideChat:
|
|
|
300
308
|
await asyncio.shield(self._opening)
|
|
301
309
|
if self._closed:
|
|
302
310
|
raise SideQuestionError(409, "Side chat was closed; open a new side chat")
|
|
311
|
+
overrides = {**self.turn_overrides, **self.provider_turn_overrides}
|
|
312
|
+
if runtime_settings is not None:
|
|
313
|
+
model = runtime_settings["model"]
|
|
314
|
+
effort = runtime_settings.get("effort") or ""
|
|
315
|
+
# Explicit default must clear a previous turn's effort;
|
|
316
|
+
# null effort alone would inherit it from the thread.
|
|
317
|
+
overrides.pop("effort", None)
|
|
318
|
+
overrides.update({"model": model, "collaborationMode": {"mode": "default", "settings": {
|
|
319
|
+
"model": model, "reasoning_effort": effort or None, "developer_instructions": None}}})
|
|
320
|
+
if effort:
|
|
321
|
+
overrides["effort"] = effort
|
|
322
|
+
if self.provider_config:
|
|
323
|
+
from codex_provider import turn_overrides
|
|
324
|
+
overrides.update(turn_overrides(model, effort,
|
|
325
|
+
summary=runtime_settings.get("reasoning_summary") or "none"))
|
|
303
326
|
turn = await self._client.start_turn(
|
|
304
327
|
self.thread_id, [{"type": "text", "text": question}],
|
|
305
|
-
overrides=
|
|
328
|
+
overrides=overrides,
|
|
306
329
|
)
|
|
330
|
+
if on_progress is not None:
|
|
331
|
+
from codex_side_chat_progress import CodexSideChatProgress
|
|
332
|
+
progress = CodexSideChatProgress(on_progress, thread_id=self.thread_id)
|
|
333
|
+
self._progress = progress
|
|
307
334
|
answers: dict[str, str] = {}
|
|
308
335
|
while True:
|
|
309
336
|
packet = await turn.next_notification()
|
|
310
337
|
method, data = packet.get("method"), packet.get("params", {})
|
|
338
|
+
if data.get("threadId") not in (None, self.thread_id):
|
|
339
|
+
continue
|
|
340
|
+
if progress is not None:
|
|
341
|
+
progress.receive(packet)
|
|
311
342
|
if method == "item/completed":
|
|
312
343
|
item = data.get("item", {})
|
|
313
344
|
if item.get("type") == "agentMessage" and item.get("phase") in (None, "", "final_answer"):
|
|
@@ -323,6 +354,8 @@ class NativeCodexSideChat:
|
|
|
323
354
|
answer = "\n\n".join(answers.values()).strip()
|
|
324
355
|
if not answer:
|
|
325
356
|
raise SideQuestionError(502, "Codex did not return a side question answer")
|
|
357
|
+
if progress is not None:
|
|
358
|
+
await progress.flush()
|
|
326
359
|
return answer
|
|
327
360
|
except CodexAppServerError:
|
|
328
361
|
await self.close()
|
|
@@ -331,9 +364,16 @@ class NativeCodexSideChat:
|
|
|
331
364
|
await self.close()
|
|
332
365
|
raise
|
|
333
366
|
finally:
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
367
|
+
try:
|
|
368
|
+
if progress is not None:
|
|
369
|
+
await progress.flush()
|
|
370
|
+
finally:
|
|
371
|
+
try:
|
|
372
|
+
if turn is not None:
|
|
373
|
+
await turn.close()
|
|
374
|
+
finally:
|
|
375
|
+
self._progress = None
|
|
376
|
+
self._active = None
|
|
337
377
|
|
|
338
378
|
async def close(self):
|
|
339
379
|
self._closed = True
|