python-codex 0.1.1__py3-none-any.whl → 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. pycodex/__init__.py +5 -1
  2. pycodex/agent.py +39 -41
  3. pycodex/cli.py +51 -43
  4. pycodex/collaboration.py +6 -7
  5. pycodex/compat.py +99 -0
  6. pycodex/context.py +87 -87
  7. pycodex/doctor.py +40 -40
  8. pycodex/model.py +69 -69
  9. pycodex/portable.py +33 -33
  10. pycodex/portable_server.py +22 -21
  11. pycodex/protocol.py +84 -86
  12. pycodex/runtime.py +36 -35
  13. pycodex/runtime_services.py +72 -69
  14. pycodex/tools/agent_tool_schemas.py +0 -2
  15. pycodex/tools/apply_patch_tool.py +43 -44
  16. pycodex/tools/base_tool.py +35 -36
  17. pycodex/tools/close_agent_tool.py +2 -4
  18. pycodex/tools/code_mode_manager.py +61 -61
  19. pycodex/tools/exec_command_tool.py +5 -6
  20. pycodex/tools/exec_runtime.js +3 -3
  21. pycodex/tools/exec_tool.py +3 -5
  22. pycodex/tools/grep_files_tool.py +10 -11
  23. pycodex/tools/list_dir_tool.py +8 -9
  24. pycodex/tools/read_file_tool.py +13 -14
  25. pycodex/tools/request_permissions_tool.py +2 -4
  26. pycodex/tools/request_user_input_tool.py +13 -14
  27. pycodex/tools/resume_agent_tool.py +2 -4
  28. pycodex/tools/send_input_tool.py +8 -9
  29. pycodex/tools/shell_command_tool.py +5 -6
  30. pycodex/tools/shell_tool.py +5 -6
  31. pycodex/tools/spawn_agent_tool.py +4 -5
  32. pycodex/tools/unified_exec_manager.py +79 -61
  33. pycodex/tools/update_plan_tool.py +4 -5
  34. pycodex/tools/view_image_tool.py +4 -5
  35. pycodex/tools/wait_agent_tool.py +2 -4
  36. pycodex/tools/wait_tool.py +4 -5
  37. pycodex/tools/web_search_tool.py +1 -3
  38. pycodex/tools/write_stdin_tool.py +4 -5
  39. pycodex/utils/dotenv.py +6 -6
  40. pycodex/utils/get_env.py +57 -34
  41. pycodex/utils/random_ids.py +1 -2
  42. pycodex/utils/visualize.py +79 -79
  43. {python_codex-0.1.1.dist-info → python_codex-0.1.3.dist-info}/METADATA +15 -9
  44. python_codex-0.1.3.dist-info/RECORD +74 -0
  45. {python_codex-0.1.1.dist-info → python_codex-0.1.3.dist-info}/WHEEL +1 -1
  46. responses_server/__init__.py +17 -0
  47. responses_server/__main__.py +5 -0
  48. responses_server/app.py +227 -0
  49. responses_server/config.py +63 -0
  50. responses_server/payload_processors.py +86 -0
  51. responses_server/server.py +63 -0
  52. responses_server/session_store.py +37 -0
  53. responses_server/stream_router.py +784 -0
  54. responses_server/tools/__init__.py +4 -0
  55. responses_server/tools/custom_adapter.py +235 -0
  56. responses_server/tools/web_search.py +263 -0
  57. python_codex-0.1.1.dist-info/RECORD +0 -62
  58. {python_codex-0.1.1.dist-info → python_codex-0.1.3.dist-info}/entry_points.txt +0 -0
  59. {python_codex-0.1.1.dist-info → python_codex-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,227 @@
1
+
2
+ import argparse
3
+ import asyncio
4
+ from dataclasses import replace
5
+ import json
6
+ import socket
7
+ import threading
8
+ import time
9
+ from typing import Iterator
10
+
11
+ from fastapi import FastAPI, Request
12
+ from fastapi.responses import JSONResponse, StreamingResponse
13
+ import uvicorn
14
+
15
+ from .config import CompatServerConfig
16
+ from .server import ResponseServer
17
+ from .stream_router import OutcommingChatError, UnsupportedIncommingFeature
18
+ import typing
19
+
20
+
21
+ def _run_uvicorn_server(server) -> 'None':
22
+ asyncio.set_event_loop(asyncio.new_event_loop())
23
+ server.run()
24
+
25
+
26
+ def _format_sse_event(event_name: 'str', payload: 'typing.Dict[str, object]') -> 'bytes':
27
+ data = json.dumps(payload, ensure_ascii=False)
28
+ return f"event: {event_name}\ndata: {data}\n\n".encode("utf-8")
29
+
30
+
31
+ def _stream_events(response_server: 'ResponseServer', request_body: 'typing.Dict[str, object]', request_headers: 'typing.Dict[str, str]') -> 'Iterator[bytes]':
32
+ try:
33
+ event_iter = response_server.start_response_stream(request_body, request_headers)
34
+ for event_name, payload in event_iter:
35
+ yield _format_sse_event(event_name, payload)
36
+ except OutcommingChatError as exc:
37
+ yield _format_sse_event(
38
+ "response.failed",
39
+ {
40
+ "type": "response.failed",
41
+ "response": {
42
+ "error": {
43
+ "message": str(exc),
44
+ }
45
+ },
46
+ },
47
+ )
48
+
49
+
50
+ def build_parser() -> 'argparse.ArgumentParser':
51
+ parser = argparse.ArgumentParser(
52
+ prog="python -m responses_server",
53
+ description=(
54
+ "Standalone localhost `/v1/responses` server that translates the "
55
+ "Codex/Responses subset onto an outcomming `/v1/chat/completions` backend."
56
+ ),
57
+ )
58
+ parser.add_argument("--host", default="127.0.0.1")
59
+ parser.add_argument("--port", type=int, default=8001)
60
+ parser.add_argument("--outcomming-base-url", required=True)
61
+ parser.add_argument("--outcomming-api-key-env", default=None)
62
+ parser.add_argument("--model-provider", default=None)
63
+ parser.add_argument("--timeout-seconds", type=float, default=120.0)
64
+ return parser
65
+
66
+
67
+ def run_server(config: 'CompatServerConfig') -> 'None':
68
+ uvicorn.run(
69
+ ManagedResponseServer.build_app(config),
70
+ host=config.host,
71
+ port=config.port,
72
+ log_level="info",
73
+ )
74
+
75
+
76
+ def launch_chat_completion_compat_server(
77
+ base_url: 'str',
78
+ api_key_env: 'typing.Union[str, None]' = None,
79
+ model_provider: 'typing.Union[str, None]' = None,
80
+ ):
81
+ config = CompatServerConfig.from_base_url(
82
+ base_url,
83
+ api_key_env,
84
+ model_provider=model_provider,
85
+ )
86
+ server = ManagedResponseServer(config)
87
+ server.start()
88
+ return server
89
+
90
+
91
+ class ManagedResponseServer:
92
+ @staticmethod
93
+ def build_app(
94
+ config: 'CompatServerConfig',
95
+ session_store=None,
96
+ stream_router=None,
97
+ ) -> 'FastAPI':
98
+ response_server = ResponseServer(
99
+ config,
100
+ session_store=session_store,
101
+ stream_router=stream_router,
102
+ )
103
+ app = FastAPI(title="ResponsesCompat", version="0.1.0")
104
+ app.state.response_server = response_server
105
+
106
+ @app.get("/health")
107
+ @app.get("/healthz")
108
+ async def health() -> 'typing.Dict[str, bool]':
109
+ return {"ok": True}
110
+
111
+ @app.get("/models")
112
+ @app.get("/v1/models")
113
+ async def list_models():
114
+ try:
115
+ return response_server.list_models()
116
+ except OutcommingChatError as exc:
117
+ return JSONResponse(
118
+ {"error": {"message": str(exc)}},
119
+ status_code=502,
120
+ )
121
+
122
+ @app.post("/responses")
123
+ @app.post("/v1/responses")
124
+ async def responses(request: 'Request'):
125
+ try:
126
+ request_body = await request.json()
127
+ except Exception as exc:
128
+ return JSONResponse(
129
+ {"error": {"message": f"invalid JSON body: {exc}"}},
130
+ status_code=400,
131
+ )
132
+ if not isinstance(request_body, dict):
133
+ return JSONResponse(
134
+ {"error": {"message": "request body must be a JSON object"}},
135
+ status_code=400,
136
+ )
137
+
138
+ request_headers = {
139
+ str(key).lower(): str(value)
140
+ for key, value in request.headers.items()
141
+ }
142
+ try:
143
+ response_server.stream_router.validate_incomming_request(request_body)
144
+ except UnsupportedIncommingFeature as exc:
145
+ return JSONResponse(
146
+ {"error": {"message": str(exc)}},
147
+ status_code=501,
148
+ )
149
+
150
+ return StreamingResponse(
151
+ _stream_events(response_server, request_body, request_headers),
152
+ media_type="text/event-stream",
153
+ headers={
154
+ "Cache-Control": "no-cache",
155
+ "Connection": "close",
156
+ },
157
+ )
158
+
159
+ return app
160
+
161
+ def __init__(self, config: 'CompatServerConfig') -> 'None':
162
+ port = config.port or _reserve_free_port()
163
+ self._config = replace(config, port=port)
164
+ self._app = self.build_app(self._config)
165
+ self._uvicorn_config = uvicorn.Config(
166
+ self._app,
167
+ host=self._config.host,
168
+ port=self._config.port,
169
+ log_level="error",
170
+ access_log=False,
171
+ )
172
+ self._server = uvicorn.Server(self._uvicorn_config)
173
+ self._thread = threading.Thread(
174
+ target=_run_uvicorn_server,
175
+ args=(self._server,),
176
+ daemon=True,
177
+ )
178
+
179
+ @property
180
+ def base_url(self) -> 'str':
181
+ return f"http://{self._config.host}:{self._config.port}/v1"
182
+
183
+ def start(self, timeout_seconds: 'float' = 10.0) -> 'None':
184
+ self._thread.start()
185
+ deadline = time.time() + timeout_seconds
186
+ while not self._server.started:
187
+ if time.time() >= deadline:
188
+ raise RuntimeError(
189
+ "timed out waiting for managed responses server to start"
190
+ )
191
+ time.sleep(0.01)
192
+
193
+ def stop(self, timeout_seconds: 'float' = 5.0) -> 'None':
194
+ self._server.should_exit = True
195
+ self._thread.join(timeout=timeout_seconds)
196
+ if self._thread.is_alive():
197
+ raise RuntimeError(
198
+ "timed out waiting for managed responses server to stop"
199
+ )
200
+
201
+
202
+ def main() -> 'None':
203
+ args = build_parser().parse_args()
204
+ run_server(
205
+ CompatServerConfig(
206
+ host=args.host,
207
+ port=args.port,
208
+ outcomming_base_url=args.outcomming_base_url,
209
+ outcomming_api_key_env=args.outcomming_api_key_env,
210
+ model_provider=args.model_provider,
211
+ timeout_seconds=args.timeout_seconds,
212
+ )
213
+ )
214
+
215
+
216
+ if __name__ == "__main__":
217
+ main()
218
+
219
+
220
+ def _reserve_free_port() -> 'int':
221
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
222
+ try:
223
+ sock.bind(("127.0.0.1", 0))
224
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
225
+ return int(sock.getsockname()[1])
226
+ finally:
227
+ sock.close()
@@ -0,0 +1,63 @@
1
+
2
+ import os
3
+ from dataclasses import dataclass
4
+ import urllib.parse
5
+ import typing
6
+
7
+
8
+ @dataclass(frozen=True, )
9
+ class CompatServerConfig:
10
+ host: 'str' = "127.0.0.1"
11
+ port: 'int' = 0
12
+ outcomming_base_url: 'str' = "http://127.0.0.1:8000/v1"
13
+ outcomming_api_key_env: 'typing.Union[str, None]' = None
14
+ model_provider: 'typing.Union[str, None]' = None
15
+ timeout_seconds: 'float' = 120.0
16
+
17
+ def outcomming_api_key(self) -> 'typing.Union[str, None]':
18
+ if self.outcomming_api_key_env is None:
19
+ return None
20
+ value = os.environ.get(self.outcomming_api_key_env, "").strip()
21
+ return value or None
22
+
23
+ def outcomming_chat_completions_url(self) -> 'str':
24
+ base = self.outcomming_base_url.rstrip("/")
25
+ return f"{base}/chat/completions"
26
+
27
+ def outcomming_models_url(self) -> 'str':
28
+ base = self.outcomming_base_url.rstrip("/")
29
+ return f"{base}/models"
30
+
31
+ def with_ephemeral_port(self) -> 'CompatServerConfig':
32
+ return CompatServerConfig(
33
+ host=self.host,
34
+ port=0,
35
+ outcomming_base_url=self.outcomming_base_url,
36
+ outcomming_api_key_env=self.outcomming_api_key_env,
37
+ model_provider=self.model_provider,
38
+ timeout_seconds=self.timeout_seconds,
39
+ )
40
+
41
+ @classmethod
42
+ def from_base_url(
43
+ cls,
44
+ outcomming_base_url: 'str',
45
+ api_key_env: 'typing.Union[str, None]' = None,
46
+ model_provider: 'typing.Union[str, None]' = None,
47
+ ) -> 'CompatServerConfig':
48
+ parsed = urllib.parse.urlparse(outcomming_base_url)
49
+ if not parsed.scheme or not parsed.netloc:
50
+ raise ValueError(f"invalid outcomming base url: {outcomming_base_url}")
51
+ normalized_path = parsed.path.rstrip("/")
52
+ if normalized_path in {"", "/"}:
53
+ parsed = parsed._replace(path="/v1")
54
+ outcomming_base_url = urllib.parse.urlunparse(parsed)
55
+ else:
56
+ outcomming_base_url = urllib.parse.urlunparse(
57
+ parsed._replace(path=normalized_path)
58
+ )
59
+ return cls(
60
+ outcomming_base_url=outcomming_base_url,
61
+ outcomming_api_key_env=api_key_env,
62
+ model_provider=model_provider,
63
+ )
@@ -0,0 +1,86 @@
1
+
2
+ """Provider-specific post-process hooks for canonical outgoing chat requests.
3
+
4
+ Each downstream chat-completions provider may have its own payload quirks:
5
+ extra fields, removed fields, role normalization, tool-shape tweaks, etc.
6
+ Keep all of those provider-specific rewrites here so `StreamRouter` can keep
7
+ building one canonical `outcomming_request`, while `server.py` selects the
8
+ appropriate hook from `CompatServerConfig.model_provider`.
9
+ """
10
+
11
+ from copy import deepcopy
12
+ from typing import Callable, Optional
13
+ import typing
14
+ from typing_extensions import TypedDict
15
+
16
+ ChatMessage = typing.Dict[str, object]
17
+
18
+
19
+ class OutgoingRequest(TypedDict):
20
+ """Canonical downstream `/v1/chat/completions` request shape.
21
+
22
+ `model`, `messages`, and `stream` are always populated by
23
+ `StreamRouter.build_outcomming_request(...)`. Provider-specific fields that
24
+ may be omitted use `Optional[...]` here so the schema stays simple and does
25
+ not rely on TypedDict inheritance.
26
+ """
27
+
28
+ model: 'str'
29
+ messages: 'typing.List[ChatMessage]'
30
+ stream: 'bool'
31
+ tools: 'Optional[typing.List[typing.Dict[str, object]]]'
32
+ tool_choice: 'Optional[object]'
33
+ parallel_tool_calls: 'Optional[bool]'
34
+
35
+
36
+ PayloadPostProcessor = Callable[[OutgoingRequest], OutgoingRequest]
37
+
38
+
39
+ def _identity(outcomming_request: 'OutgoingRequest') -> 'OutgoingRequest':
40
+ """Keep the canonical request unchanged."""
41
+
42
+ return outcomming_request
43
+
44
+
45
+ def _drop_developer_messages(outcomming_request: 'OutgoingRequest') -> 'OutgoingRequest':
46
+ """Remove all developer-role messages for providers that reject them."""
47
+
48
+ outcomming_request["messages"] = [
49
+ message
50
+ for message in outcomming_request["messages"]
51
+ if message.get("role") != "developer"
52
+ ]
53
+ return outcomming_request
54
+
55
+
56
+ PAYLOAD_POST_PROCESSORS: 'typing.Dict[str, PayloadPostProcessor]' = {
57
+ "stepfun": _drop_developer_messages,
58
+ "vllm": _identity,
59
+ }
60
+ """Mapping from normalized `model_provider` name to payload rewrite hook."""
61
+
62
+
63
+ def post_process_outcomming_request(
64
+ outcomming_request: 'OutgoingRequest',
65
+ model_provider: 'typing.Union[str, None]',
66
+ ) -> 'OutgoingRequest':
67
+ """Apply the provider-specific payload hook to one outgoing request.
68
+
69
+ This is the single wrapper around `PAYLOAD_POST_PROCESSORS`: it normalizes
70
+ the provider name, falls back to the default `vllm` behavior when the
71
+ provider is missing or unknown, deep-copies the canonical request, applies
72
+ the selected hook, and validates that the hook returns another request dict.
73
+ """
74
+
75
+ processed_request = deepcopy(outcomming_request)
76
+ provider_name = str(model_provider or "").strip().lower()
77
+ provider_processor = PAYLOAD_POST_PROCESSORS.get(
78
+ provider_name,
79
+ PAYLOAD_POST_PROCESSORS.get("vllm"),
80
+ )
81
+ if provider_processor is None:
82
+ return processed_request
83
+ processed_request = provider_processor(processed_request)
84
+ if not isinstance(processed_request, dict):
85
+ raise TypeError("payload processor must return a dict outcomming_request")
86
+ return processed_request
@@ -0,0 +1,63 @@
1
+
2
+ from .config import CompatServerConfig
3
+ from .payload_processors import post_process_outcomming_request
4
+ from .session_store import SessionStore
5
+ from .stream_router import StreamRouter
6
+ import typing
7
+
8
+
9
+ class ResponseServer:
10
+ def __init__(
11
+ self,
12
+ config: 'CompatServerConfig',
13
+ session_store: 'typing.Union[SessionStore, None]' = None,
14
+ stream_router: 'typing.Union[StreamRouter, None]' = None,
15
+ ) -> 'None':
16
+ self._config = config
17
+ self._session_store = session_store or SessionStore()
18
+ self._stream_router = stream_router or StreamRouter(config)
19
+
20
+ @property
21
+ def config(self) -> 'CompatServerConfig':
22
+ return self._config
23
+
24
+ @property
25
+ def session_store(self) -> 'SessionStore':
26
+ return self._session_store
27
+
28
+ @property
29
+ def stream_router(self) -> 'StreamRouter':
30
+ return self._stream_router
31
+
32
+ def list_models(self) -> 'typing.Dict[str, object]':
33
+ return self._stream_router.list_models()
34
+
35
+ def start_response_stream(
36
+ self,
37
+ request_body: 'typing.Dict[str, object]',
38
+ request_headers: 'typing.Dict[str, str]',
39
+ ):
40
+ outcomming_request = self._stream_router.build_outcomming_request(request_body)
41
+ outcomming_request = post_process_outcomming_request(
42
+ outcomming_request,
43
+ self._config.model_provider,
44
+ )
45
+ custom_tool_names = self._stream_router.collect_custom_tool_names(request_body)
46
+ session_id = (
47
+ request_headers.get("x-client-request-id")
48
+ or str(request_body.get("prompt_cache_key", "")).strip()
49
+ or None
50
+ )
51
+ stored_response = self._session_store.create_response(
52
+ session_id=session_id,
53
+ model=str(outcomming_request["model"]),
54
+ )
55
+ incomming_stream = self._stream_router.open_outcomming_stream(
56
+ outcomming_request
57
+ )
58
+ return self._stream_router.route_stream(
59
+ incomming_stream,
60
+ stored_response,
61
+ outcomming_request,
62
+ custom_tool_names,
63
+ )
@@ -0,0 +1,37 @@
1
+
2
+ from dataclasses import dataclass
3
+ import threading
4
+ import time
5
+ import typing
6
+
7
+
8
+ @dataclass(frozen=True, )
9
+ class StoredResponse:
10
+ response_id: 'str'
11
+ session_id: 'typing.Union[str, None]'
12
+ model: 'str'
13
+ created_at: 'float'
14
+
15
+
16
+ class SessionStore:
17
+ def __init__(self) -> 'None':
18
+ self._lock = threading.Lock()
19
+ self._next_response_number = 1
20
+ self._responses: 'typing.Dict[str, StoredResponse]' = {}
21
+
22
+ def create_response(self, session_id: 'typing.Union[str, None]', model: 'str') -> 'StoredResponse':
23
+ with self._lock:
24
+ response_id = f"resp_{self._next_response_number:08d}"
25
+ self._next_response_number += 1
26
+ stored = StoredResponse(
27
+ response_id=response_id,
28
+ session_id=session_id,
29
+ model=model,
30
+ created_at=time.time(),
31
+ )
32
+ self._responses[response_id] = stored
33
+ return stored
34
+
35
+ def get_response(self, response_id: 'str') -> 'typing.Union[StoredResponse, None]':
36
+ with self._lock:
37
+ return self._responses.get(response_id)