python-codex 0.1.8__tar.gz → 0.1.10__tar.gz

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 (105) hide show
  1. {python_codex-0.1.8 → python_codex-0.1.10}/AGENTS.md +2 -0
  2. {python_codex-0.1.8 → python_codex-0.1.10}/PKG-INFO +1 -1
  3. {python_codex-0.1.8 → python_codex-0.1.10}/docs/responses_server/README.md +14 -0
  4. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/context.py +17 -12
  5. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/model.py +1 -1
  6. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/portable.py +4 -3
  7. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/base_tool.py +2 -1
  8. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/utils/dotenv.py +3 -1
  9. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/utils/get_env.py +5 -1
  10. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/utils/session_persist.py +4 -2
  11. {python_codex-0.1.8 → python_codex-0.1.10}/pyproject.toml +1 -1
  12. {python_codex-0.1.8 → python_codex-0.1.10}/responses_server/app.py +1 -0
  13. {python_codex-0.1.8 → python_codex-0.1.10}/responses_server/messages_api.py +2 -0
  14. {python_codex-0.1.8 → python_codex-0.1.10}/responses_server/payload_processors.py +1 -0
  15. {python_codex-0.1.8 → python_codex-0.1.10}/responses_server/server.py +6 -4
  16. {python_codex-0.1.8 → python_codex-0.1.10}/responses_server/stream_router.py +20 -3
  17. python_codex-0.1.10/responses_server/trajectory_dump.py +105 -0
  18. {python_codex-0.1.8 → python_codex-0.1.10}/tests/responses_server/fake_chat_completions_server.py +24 -13
  19. {python_codex-0.1.8 → python_codex-0.1.10}/tests/responses_server/test_server.py +207 -0
  20. {python_codex-0.1.8 → python_codex-0.1.10}/tests/test_cli.py +67 -0
  21. {python_codex-0.1.8 → python_codex-0.1.10}/.github/workflows/publish.yml +0 -0
  22. {python_codex-0.1.8 → python_codex-0.1.10}/.github/workflows/test.yml +0 -0
  23. {python_codex-0.1.8 → python_codex-0.1.10}/.gitignore +0 -0
  24. {python_codex-0.1.8 → python_codex-0.1.10}/LICENSE +0 -0
  25. {python_codex-0.1.8 → python_codex-0.1.10}/README.md +0 -0
  26. {python_codex-0.1.8 → python_codex-0.1.10}/README_ZH.md +0 -0
  27. {python_codex-0.1.8 → python_codex-0.1.10}/docs/ALIGNMENT.md +0 -0
  28. {python_codex-0.1.8 → python_codex-0.1.10}/docs/CONTEXT.md +0 -0
  29. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/__init__.py +0 -0
  30. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/agent.py +0 -0
  31. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/cli.py +0 -0
  32. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/collaboration.py +0 -0
  33. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/compat.py +0 -0
  34. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/doctor.py +0 -0
  35. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/portable_server.py +0 -0
  36. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/collaboration_default.md +0 -0
  37. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/collaboration_plan.md +0 -0
  38. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/default_base_instructions.md +0 -0
  39. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/exec_tools.json +0 -0
  40. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/models.json +0 -0
  41. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/permissions/approval_policy/never.md +0 -0
  42. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/permissions/approval_policy/on_failure.md +0 -0
  43. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/permissions/approval_policy/on_request.md +0 -0
  44. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +0 -0
  45. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/permissions/approval_policy/unless_trusted.md +0 -0
  46. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +0 -0
  47. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/permissions/sandbox_mode/read_only.md +0 -0
  48. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/permissions/sandbox_mode/workspace_write.md +0 -0
  49. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/prompts/subagent_tools.json +0 -0
  50. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/protocol.py +0 -0
  51. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/runtime.py +0 -0
  52. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/runtime_services.py +0 -0
  53. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/__init__.py +0 -0
  54. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/agent_tool_schemas.py +0 -0
  55. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/apply_patch_tool.py +0 -0
  56. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/close_agent_tool.py +0 -0
  57. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/code_mode_manager.py +0 -0
  58. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/exec_command_tool.py +0 -0
  59. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/exec_runtime.js +0 -0
  60. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/exec_tool.py +0 -0
  61. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/grep_files_tool.py +0 -0
  62. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/list_dir_tool.py +0 -0
  63. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/read_file_tool.py +0 -0
  64. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/request_permissions_tool.py +0 -0
  65. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/request_user_input_tool.py +0 -0
  66. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/resume_agent_tool.py +0 -0
  67. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/send_input_tool.py +0 -0
  68. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/shell_command_tool.py +0 -0
  69. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/shell_tool.py +0 -0
  70. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/spawn_agent_tool.py +0 -0
  71. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/unified_exec_manager.py +0 -0
  72. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/update_plan_tool.py +0 -0
  73. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/view_image_tool.py +0 -0
  74. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/wait_agent_tool.py +0 -0
  75. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/wait_tool.py +0 -0
  76. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/web_search_tool.py +0 -0
  77. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/tools/write_stdin_tool.py +0 -0
  78. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/utils/__init__.py +0 -0
  79. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/utils/compactor.py +0 -0
  80. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/utils/debug.py +0 -0
  81. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/utils/random_ids.py +0 -0
  82. {python_codex-0.1.8 → python_codex-0.1.10}/pycodex/utils/visualize.py +0 -0
  83. {python_codex-0.1.8 → python_codex-0.1.10}/responses_server/__init__.py +0 -0
  84. {python_codex-0.1.8 → python_codex-0.1.10}/responses_server/__main__.py +0 -0
  85. {python_codex-0.1.8 → python_codex-0.1.10}/responses_server/config.py +0 -0
  86. {python_codex-0.1.8 → python_codex-0.1.10}/responses_server/session_store.py +0 -0
  87. {python_codex-0.1.8 → python_codex-0.1.10}/responses_server/tools/__init__.py +0 -0
  88. {python_codex-0.1.8 → python_codex-0.1.10}/responses_server/tools/custom_adapter.py +0 -0
  89. {python_codex-0.1.8 → python_codex-0.1.10}/responses_server/tools/web_search.py +0 -0
  90. {python_codex-0.1.8 → python_codex-0.1.10}/tests/TESTS.md +0 -0
  91. {python_codex-0.1.8 → python_codex-0.1.10}/tests/__init__.py +0 -0
  92. {python_codex-0.1.8 → python_codex-0.1.10}/tests/compare_request_user_input_roundtrip.py +0 -0
  93. {python_codex-0.1.8 → python_codex-0.1.10}/tests/compare_steer_request_bodies.py +0 -0
  94. {python_codex-0.1.8 → python_codex-0.1.10}/tests/compare_tool_schemas.py +0 -0
  95. {python_codex-0.1.8 → python_codex-0.1.10}/tests/fake_responses_server.py +0 -0
  96. {python_codex-0.1.8 → python_codex-0.1.10}/tests/fakes.py +0 -0
  97. {python_codex-0.1.8 → python_codex-0.1.10}/tests/test_agent.py +0 -0
  98. {python_codex-0.1.8 → python_codex-0.1.10}/tests/test_builtin_tools.py +0 -0
  99. {python_codex-0.1.8 → python_codex-0.1.10}/tests/test_compactor.py +0 -0
  100. {python_codex-0.1.8 → python_codex-0.1.10}/tests/test_context.py +0 -0
  101. {python_codex-0.1.8 → python_codex-0.1.10}/tests/test_doctor.py +0 -0
  102. {python_codex-0.1.8 → python_codex-0.1.10}/tests/test_fake_responses_server.py +0 -0
  103. {python_codex-0.1.8 → python_codex-0.1.10}/tests/test_model.py +0 -0
  104. {python_codex-0.1.8 → python_codex-0.1.10}/tests/test_portable.py +0 -0
  105. {python_codex-0.1.8 → python_codex-0.1.10}/tests/test_py36_syntax.py +0 -0
@@ -15,6 +15,7 @@
15
15
  - `responses_server` 的 provider-specific chat payload 定制统一放在 `responses_server/payload_processors.py`:使用 `CompatServerConfig.model_provider` 选择 `provider_name -> proc_fn(outcomming_request)` 映射,并且只在真正发出 downstream `/v1/chat/completions` 前 post-process;`StreamRouter` 内部继续保留 canonical payload,避免 tool hydration / mock web_search follow-up 被 provider 改写污染。
16
16
  - `responses_server` 如果要兼容下游 `/v1/messages`,也优先保持这条边界:内部继续用 canonical chat request / chat-like chunk 流,只有真正发请求和读取 SSE 时才做 messages 适配,这样 tool hydration、mock `web_search` follow-up、provider payload post-process 都能复用。
17
17
  - 真实 vLLM `0.19.0` 的 `/v1/messages` 会对缺失 `max_tokens` 直接返回 `400`;messages 适配层必须总是补这个字段。当前约定是优先透传请求里的 `max_output_tokens`/`max_tokens`,否则回退到默认 `32000`。
18
+ - 对 vLLM chat-completions 打开 `return_token_ids=true` 时,streaming `prompt_token_ids` 只出现在首个 chunk,后续每个 chunk 的 `choices[*].token_ids` 都是 decode delta;要在 `responses_server` 侧导出 trajectory 时,按“首个 `prompt_token_ids` + 按序拼接所有 chunk 的 `token_ids`”重建即可。
18
19
  - `pycodex` 默认是最小交互 CLI;无 prompt 时进入 REPL,并通过 `AgentRuntime` 跑外层提交循环。当前会显示最小事件流、assistant 流式输出、简单 title/history(`/title`, `/history`),并默认注册一组与原版一一对应的本地工具子集。
19
20
  - 交互 CLI 的事件流展示优先表达用户可感知的阶段(例如工具开始/完成、模型回看工具结果),不要直接把内部 `iteration` 计数暴露成主要状态文案;`iterations` 应继续保留在 `TurnResult` 等程序化结果里。
20
21
  - prompt/context 相关逻辑统一放在 `pycodex/context.py`:`AgentLoop` 只维护真实会话历史;每轮请求前由 `ContextManager` 注入 base instructions、developer message、`AGENTS.md` 指令和 `<environment_context>`,且这些注入项不写回 history。
@@ -49,5 +50,6 @@
49
50
  - `--put` 的 CLI UX 现在约定为“先打印打包文件清单和上传目标,再打印结果”,并且最后一行保留为可直接执行的 `pycodex --call SECRET-CALLID@<host:port>` 一键启动命令;后续如果再改输出,尽量保留这个末行语义。
50
51
  - `--put` 现在不是“只上传就结束”:上传成功后还会立刻跑一次真实的 `--call` 下载/解包 round-trip 测试,只有这个测试也成功才算整个 `--put` 成功。排障时如果上传成功但 CLI 仍退出非零,要继续看 call 路径而不只看 put 端。
51
52
  - `--put` 现在会先做 `/healthz` preflight,再开始扫描/压缩目录;如果用户报“卡死”,先看目标 `host:port` 是否真有 storage server 在监听。默认打包策略也已经切到白名单:只带 `config.toml`、`.env`、`AGENTS.md`、`AGENTS.override.md`、`skills/**`,以及 config 里相对引用的 `model_instructions_file`,所以像 `sessions/` 这类运行态目录不会再靠黑名单排除。
53
+ - `--call` / portable storage paths must not rely on the process default text encoding. Always pass `encoding="utf-8"` when reading config, prompts, AGENTS files, skills, dotenv, and session history; for user-authored instructions/history, prefer `errors="replace"` so a Windows GBK locale cannot crash on UTF-8 punctuation such as U+2264 or em dash.
52
54
  - 对接真实 `~/.codex/sessions/.../rollout-*.jsonl` 时,不要假设它一定是严格的一行一个 JSON object:本机样本可能包含 pretty-printed 多行对象,且文件尾部偶尔带未完成记录。恢复历史时用 concatenated-JSON 方式读取,并容忍尾部残缺。
53
55
  - `pycodex` 本地 session 保存现在也按上游思路走:新 session 一开始就分配稳定的 uuidv7 thread/session id,并把历史增量追加到 `CODEX_HOME/sessions/.../rollout-*.jsonl`;`/resume` 列表应只展示至少有真实 user message 的 rollout,避免空白新 session 污染恢复列表。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-codex
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: A minimal Python extraction of Codex's main agent loop
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.6.2
@@ -26,6 +26,7 @@
26
26
  - vLLM chat-completions `reasoning` / `reasoning_content` -> Responses `reasoning` item 适配
27
27
  - vLLM 历史 `reasoning` item -> assistant message `reasoning` 字段回放
28
28
  - vLLM streaming `usage` -> final `response.completed.response.usage`
29
+ - 当环境变量 `PYCODEX_DUMP` 存在时,为每条 outcomming 请求附加 `return_token_ids = true`,并把抓到的 `prompt_token_ids` / `token_ids` 以 JSONL 追加到 `{PYCODEX_DUMP}/dump.jsonl`
29
30
  - 下游 chat stream 如果半路断开,会转成上游可解析的 `response.failed` 事件,而不是直接截断 HTTP body
30
31
  - 普通 function tools
31
32
  - custom tools 的 function-wrapper 兼容适配
@@ -76,6 +77,19 @@ uv run python -m responses_server \
76
77
  默认会在本地启动一个 incomming Responses 服务;真正监听地址由 `--host` 和 `--port`
77
78
  控制。
78
79
 
80
+ 如果要导出下游 trajectory token id,可以在启动前设置:
81
+
82
+ ```bash
83
+ export PYCODEX_DUMP=/tmp/pycodex-dump
84
+ ```
85
+
86
+ server 会为每条实际转发到下游的请求附上 `return_token_ids = true`,并把
87
+ trajectory 追加到 `${PYCODEX_DUMP}/dump.jsonl`,当前记录格式是:
88
+
89
+ ```json
90
+ {"tokens":{"prefill":[1,2,3],"decode":[4,5,6]},"send_timestamp":2222.0}
91
+ ```
92
+
79
93
  如果下游 provider 需要对 chat payload 做定制化改写,可以在
80
94
  `responses_server/payload_processors.py` 里注册对应 `model_provider -> proc_fn`
81
95
  映射;server 会在真正发出每一条 outcomming `/v1/chat/completions` 请求前,
@@ -89,7 +89,7 @@ class ContextConfig:
89
89
  profile: 'typing.Union[str, None]' = None,
90
90
  ) -> 'ContextConfig':
91
91
  path = Path(config_path)
92
- data = tomllib.loads(path.read_text())
92
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
93
93
  selected = dict(data)
94
94
  if profile is not None:
95
95
  overrides = data.get("profiles", {}).get(profile)
@@ -162,7 +162,9 @@ class ContextManager:
162
162
  self._include_permissions_instructions = include_permissions_instructions
163
163
  self._include_skills_instructions = include_skills_instructions
164
164
  self._network_access = network_access
165
- self._default_base_instructions = DEFAULT_BASE_INSTRUCTIONS_PATH.read_text()
165
+ self._default_base_instructions = DEFAULT_BASE_INSTRUCTIONS_PATH.read_text(
166
+ encoding="utf-8"
167
+ )
166
168
  self._workspace_metadata_turn_id: 'typing.Union[str, None]' = None
167
169
  self._workspace_metadata_cache: 'typing.Union[JSONDict, None]' = None
168
170
 
@@ -237,7 +239,10 @@ class ContextManager:
237
239
  if self._config.base_instructions is not None:
238
240
  return self._config.base_instructions
239
241
  if self._config.model_instructions_file is not None:
240
- return self._config.model_instructions_file.read_text().strip()
242
+ return self._config.model_instructions_file.read_text(
243
+ encoding="utf-8",
244
+ errors="replace",
245
+ ).strip()
241
246
  resolved = self._resolve_model_instructions()
242
247
  if resolved is not None:
243
248
  return resolved
@@ -327,11 +332,11 @@ class ContextManager:
327
332
  return None
328
333
 
329
334
  sandbox_text = (
330
- sandbox_prompt_path.read_text().strip().replace(
335
+ sandbox_prompt_path.read_text(encoding="utf-8").strip().replace(
331
336
  "{network_access}", self._network_access
332
337
  )
333
338
  )
334
- approval_text = approval_prompt_path.read_text().strip()
339
+ approval_text = approval_prompt_path.read_text(encoding="utf-8").strip()
335
340
  return "\n".join(
336
341
  [
337
342
  PERMISSIONS_OPEN_TAG,
@@ -429,7 +434,7 @@ class ContextManager:
429
434
  docs: 'typing.List[str]' = []
430
435
  remaining = self._config.project_doc_max_bytes
431
436
  for path in self._discover_project_doc_paths():
432
- text = path.read_text()
437
+ text = path.read_text(encoding="utf-8", errors="replace")
433
438
  if not text.strip():
434
439
  continue
435
440
  if remaining is None:
@@ -437,7 +442,7 @@ class ContextManager:
437
442
  continue
438
443
  if remaining <= 0:
439
444
  break
440
- encoded = text.encode()
445
+ encoded = text.encode("utf-8")
441
446
  docs.append(encoded[:remaining].decode(errors="ignore"))
442
447
  remaining -= min(len(encoded), remaining)
443
448
  if not docs:
@@ -507,15 +512,15 @@ def _normalize_int(value) -> 'typing.Union[int, None]':
507
512
 
508
513
  def _default_collaboration_instructions(mode: 'CollaborationMode') -> 'str':
509
514
  if mode == "plan":
510
- return PLAN_COLLABORATION_INSTRUCTIONS_PATH.read_text()
511
- return DEFAULT_COLLABORATION_INSTRUCTIONS_PATH.read_text()
515
+ return PLAN_COLLABORATION_INSTRUCTIONS_PATH.read_text(encoding="utf-8")
516
+ return DEFAULT_COLLABORATION_INSTRUCTIONS_PATH.read_text(encoding="utf-8")
512
517
 
513
518
 
514
519
  def _read_first_instruction_file(base: 'Path') -> 'typing.Union[str, None]':
515
520
  for candidate_name in (LOCAL_PROJECT_DOC_FILENAME, DEFAULT_PROJECT_DOC_FILENAME):
516
521
  candidate = base / candidate_name
517
522
  try:
518
- contents = candidate.read_text()
523
+ contents = candidate.read_text(encoding="utf-8", errors="replace")
519
524
  except OSError:
520
525
  continue
521
526
  trimmed = contents.strip()
@@ -526,7 +531,7 @@ def _read_first_instruction_file(base: 'Path') -> 'typing.Union[str, None]':
526
531
 
527
532
  @lru_cache(maxsize=1)
528
533
  def _load_models_by_slug() -> 'typing.Dict[str, JSONDict]':
529
- payload = json.loads(DEFAULT_MODELS_PATH.read_text())
534
+ payload = json.loads(DEFAULT_MODELS_PATH.read_text(encoding="utf-8"))
530
535
  models = payload.get("models", [])
531
536
  by_slug: 'typing.Dict[str, JSONDict]' = {}
532
537
  for model in models:
@@ -571,7 +576,7 @@ def _discover_skill_files(
571
576
 
572
577
 
573
578
  def _parse_skill_descriptor(path: 'Path', scope_rank: 'int') -> 'typing.Union[SkillDescriptor, None]':
574
- text = path.read_text()
579
+ text = path.read_text(encoding="utf-8", errors="replace")
575
580
  if not text.startswith("---\n"):
576
581
  return None
577
582
  end_marker = "\n---\n"
@@ -71,7 +71,7 @@ class ResponsesProviderConfig:
71
71
  config_path: 'typing.Union[str, Path]' = DEFAULT_CODEX_CONFIG_PATH,
72
72
  profile: 'typing.Union[str, None]' = None,
73
73
  ) -> 'ResponsesProviderConfig':
74
- data = tomllib.loads(Path(config_path).read_text())
74
+ data = tomllib.loads(Path(config_path).read_text(encoding="utf-8"))
75
75
  selected = dict(data)
76
76
  if profile is not None:
77
77
  overrides = data.get("profiles", {}).get(profile)
@@ -123,7 +123,8 @@ def bootstrap_called_home(
123
123
  },
124
124
  ensure_ascii=False,
125
125
  indent=2,
126
- )
126
+ ),
127
+ encoding="utf-8",
127
128
  )
128
129
  return home_dir / DEFAULT_ENTRY_CONFIG
129
130
 
@@ -199,7 +200,7 @@ def _collect_config_referenced_files(root: 'Path') -> 'typing.Set[str]':
199
200
  config_path = root / DEFAULT_ENTRY_CONFIG
200
201
  if not config_path.is_file():
201
202
  return set()
202
- data = tomllib.loads(config_path.read_text())
203
+ data = tomllib.loads(config_path.read_text(encoding="utf-8"))
203
204
  referenced: 'typing.Set[str]' = set()
204
205
  candidates = [data]
205
206
  profiles = data.get("profiles")
@@ -352,7 +353,7 @@ def _load_cached_metadata(metadata_path: 'Path') -> 'typing.Dict[str, object]':
352
353
  if not metadata_path.is_file():
353
354
  return {}
354
355
  try:
355
- payload = json.loads(metadata_path.read_text())
356
+ payload = json.loads(metadata_path.read_text(encoding="utf-8"))
356
357
  except (ValueError, OSError):
357
358
  return {}
358
359
  return payload if isinstance(payload, dict) else {}
@@ -30,7 +30,8 @@ EXEC_TOOLS_SNAPSHOT_PATH = (
30
30
  @lru_cache(maxsize=1)
31
31
  def _load_exec_tool_payloads() -> 'typing.Dict[str, JSONDict]':
32
32
  payloads: 'typing.Dict[str, JSONDict]' = {}
33
- for payload in json.loads(EXEC_TOOLS_SNAPSHOT_PATH.read_text()):
33
+ raw_payloads = EXEC_TOOLS_SNAPSHOT_PATH.read_text(encoding="utf-8")
34
+ for payload in json.loads(raw_payloads):
34
35
  if not isinstance(payload, dict):
35
36
  continue
36
37
  name = payload.get("name")
@@ -18,7 +18,9 @@ def load_codex_dotenv(config_path: 'typing.Union[str, Path]') -> 'None':
18
18
  _LOADED_CODEX_DOTENV_HOMES.add(codex_home)
19
19
  return
20
20
 
21
- for key, value in parse_dotenv(dotenv_path.read_text()).items():
21
+ for key, value in parse_dotenv(
22
+ dotenv_path.read_text(encoding="utf-8", errors="replace")
23
+ ).items():
22
24
  if key.upper().startswith(ILLEGAL_ENV_VAR_PREFIX):
23
25
  continue
24
26
  os.environ[key] = value
@@ -98,7 +98,11 @@ def get_os_info() -> 'typing.Tuple[str, str]':
98
98
  os_release = Path("/etc/os-release")
99
99
  if os_release.is_file():
100
100
  values: 'typing.Dict[str, str]' = {}
101
- for line in os_release.read_text().splitlines():
101
+ os_release_text = os_release.read_text(
102
+ encoding="utf-8",
103
+ errors="replace",
104
+ )
105
+ for line in os_release_text.splitlines():
102
106
  if "=" not in line:
103
107
  continue
104
108
  key, value = line.split("=", 1)
@@ -282,7 +282,9 @@ def _latest_thread_names_by_id(codex_home: 'Path') -> 'typing.Dict[str, str]':
282
282
  return {}
283
283
 
284
284
  names_by_id: 'typing.Dict[str, str]' = {}
285
- for raw_line in reversed(index_path.read_text().splitlines()):
285
+ for raw_line in reversed(
286
+ index_path.read_text(encoding="utf-8", errors="replace").splitlines()
287
+ ):
286
288
  line = raw_line.strip()
287
289
  if not line:
288
290
  continue
@@ -321,7 +323,7 @@ def _extract_first_user_message_preview(rollout_path: 'Path') -> 'typing.Union[s
321
323
 
322
324
 
323
325
  def _iter_rollout_entries(rollout_path: 'Path') -> 'typing.Iterable[typing.Dict[str, object]]':
324
- text = rollout_path.read_text()
326
+ text = rollout_path.read_text(encoding="utf-8", errors="replace")
325
327
  decoder = json.JSONDecoder()
326
328
  index = 0
327
329
  parsed_entries = 0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-codex"
7
- version = "0.1.8"
7
+ version = "0.1.10"
8
8
  description = "A minimal Python extraction of Codex's main agent loop"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.6.2"
@@ -177,6 +177,7 @@ class ManagedResponseServer:
177
177
  self._app,
178
178
  host=self._config.host,
179
179
  port=self._config.port,
180
+ loop="asyncio",
180
181
  log_level="error",
181
182
  access_log=False,
182
183
  )
@@ -66,6 +66,8 @@ def build_messages_request(
66
66
  "max_tokens": _resolve_max_tokens(outcomming_request),
67
67
  "stream": bool(outcomming_request.get("stream", True)),
68
68
  }
69
+ if isinstance(outcomming_request.get("return_token_ids"), bool):
70
+ payload["return_token_ids"] = bool(outcomming_request.get("return_token_ids"))
69
71
  if system_blocks:
70
72
  payload["system"] = system_blocks
71
73
 
@@ -32,6 +32,7 @@ class OutgoingRequest(TypedDict):
32
32
  tools: 'Optional[typing.List[typing.Dict[str, object]]]'
33
33
  tool_choice: 'Optional[object]'
34
34
  parallel_tool_calls: 'Optional[bool]'
35
+ return_token_ids: 'Optional[bool]'
35
36
 
36
37
 
37
38
  PayloadPostProcessor = Callable[[OutgoingRequest], OutgoingRequest]
@@ -3,6 +3,7 @@ from .config import CompatServerConfig
3
3
  from .payload_processors import post_process_outcomming_request
4
4
  from .session_store import SessionStore
5
5
  from .stream_router import StreamRouter
6
+ from .trajectory_dump import TrajectoryDumpWriter
6
7
  import typing
7
8
 
8
9
 
@@ -16,6 +17,7 @@ class ResponseServer:
16
17
  self._config = config
17
18
  self._session_store = session_store or SessionStore()
18
19
  self._stream_router = stream_router or StreamRouter(config)
20
+ self._trajectory_dump = TrajectoryDumpWriter.from_env()
19
21
 
20
22
  @property
21
23
  def config(self) -> 'CompatServerConfig':
@@ -38,6 +40,9 @@ class ResponseServer:
38
40
  request_headers: 'typing.Dict[str, str]',
39
41
  ):
40
42
  outcomming_request = self._stream_router.build_outcomming_request(request_body)
43
+ if self._trajectory_dump is not None:
44
+ # vLLM surfaces prompt/decode token IDs only when this flag is set.
45
+ outcomming_request["return_token_ids"] = True
41
46
  outcomming_request = post_process_outcomming_request(
42
47
  outcomming_request,
43
48
  self._config.model_provider,
@@ -52,12 +57,9 @@ class ResponseServer:
52
57
  session_id=session_id,
53
58
  model=str(outcomming_request["model"]),
54
59
  )
55
- incomming_stream = self._stream_router.open_outcomming_stream(
56
- outcomming_request
57
- )
58
60
  return self._stream_router.route_stream(
59
- incomming_stream,
60
61
  stored_response,
61
62
  outcomming_request,
62
63
  custom_tool_names,
64
+ self._trajectory_dump,
63
65
  )
@@ -27,6 +27,7 @@ from .tools.web_search import (
27
27
  hydrate_tool_call_names,
28
28
  partition_tool_calls,
29
29
  )
30
+ from .trajectory_dump import TrajectoryDumpWriter
30
31
  import typing
31
32
 
32
33
 
@@ -285,10 +286,10 @@ class StreamRouter:
285
286
 
286
287
  def route_stream(
287
288
  self,
288
- incomming_stream,
289
289
  stored_response: 'StoredResponse',
290
290
  outcomming_request: 'typing.Dict[str, object]',
291
291
  custom_tool_names: 'typing.Union[typing.Set[str], None]' = None,
292
+ trajectory_dump: 'typing.Union[TrajectoryDumpWriter, None]' = None,
292
293
  ):
293
294
  yield (
294
295
  "response.created",
@@ -307,7 +308,10 @@ class StreamRouter:
307
308
  reasoning_parts: 'typing.List[str]' = []
308
309
  latest_usage: 'typing.Dict[str, object]' = {}
309
310
  current_request = json.loads(json.dumps(outcomming_request))
310
- current_stream = incomming_stream
311
+ current_stream = self._open_tracked_outcomming_stream(
312
+ current_request,
313
+ trajectory_dump,
314
+ )
311
315
 
312
316
  while True:
313
317
  tool_calls: 'typing.Dict[int, typing.Dict[str, object]]' = {}
@@ -352,7 +356,10 @@ class StreamRouter:
352
356
  )
353
357
  except ValueError as exc:
354
358
  raise OutcommingChatError(str(exc)) from exc
355
- current_stream = self.open_outcomming_stream(current_request)
359
+ current_stream = self._open_tracked_outcomming_stream(
360
+ current_request,
361
+ trajectory_dump,
362
+ )
356
363
  continue
357
364
 
358
365
  for item in self._build_output_items(
@@ -394,6 +401,16 @@ class StreamRouter:
394
401
  },
395
402
  )
396
403
 
404
+ def _open_tracked_outcomming_stream(
405
+ self,
406
+ outcomming_request: 'typing.Dict[str, object]',
407
+ trajectory_dump: 'typing.Union[TrajectoryDumpWriter, None]' = None,
408
+ ):
409
+ outcomming_stream = self.open_outcomming_stream(outcomming_request)
410
+ if trajectory_dump is None:
411
+ return outcomming_stream
412
+ return trajectory_dump.wrap_stream(outcomming_stream)
413
+
397
414
  def _responses_input_to_chat_messages(
398
415
  self,
399
416
  instructions: 'str',
@@ -0,0 +1,105 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import threading
5
+ import time
6
+ import typing
7
+
8
+
9
+ class TrajectoryDumpWriter:
10
+ ENV_VAR = "PYCODEX_DUMP"
11
+
12
+ def __init__(self, root_dir: 'str') -> 'None':
13
+ self._root_dir = os.path.abspath(root_dir)
14
+ self._dump_path = os.path.join(self._root_dir, "dump.jsonl")
15
+ self._lock = threading.Lock()
16
+ os.makedirs(self._root_dir, exist_ok=True)
17
+
18
+ @classmethod
19
+ def from_env(cls) -> 'typing.Union[TrajectoryDumpWriter, None]':
20
+ root_dir = str(os.environ.get(cls.ENV_VAR, "") or "").strip()
21
+ if not root_dir:
22
+ return None
23
+ return cls(root_dir)
24
+
25
+ def wrap_stream(self, outcomming_stream):
26
+ def iter_stream():
27
+ capture = _TrajectoryCapture(self, time.time())
28
+ try:
29
+ for chunk in outcomming_stream:
30
+ capture.observe_chunk(chunk)
31
+ yield chunk
32
+ finally:
33
+ capture.flush()
34
+
35
+ return iter_stream()
36
+
37
+ def _append_record(self, record: 'typing.Dict[str, object]') -> 'None':
38
+ serialized = json.dumps(record, ensure_ascii=False)
39
+ with self._lock:
40
+ os.makedirs(self._root_dir, exist_ok=True)
41
+ with open(self._dump_path, "a", encoding="utf-8") as handle:
42
+ handle.write(serialized)
43
+ handle.write("\n")
44
+
45
+
46
+ class _TrajectoryCapture:
47
+ def __init__(
48
+ self,
49
+ writer: 'TrajectoryDumpWriter',
50
+ send_timestamp: 'float',
51
+ ) -> 'None':
52
+ self._writer = writer
53
+ self._send_timestamp = float(send_timestamp)
54
+ self._prefill_token_ids = None
55
+ self._decode_token_ids = []
56
+ self._closed = False
57
+
58
+ def observe_chunk(self, payload: 'object') -> 'None':
59
+ if not isinstance(payload, dict):
60
+ return
61
+ if self._prefill_token_ids is None and "prompt_token_ids" in payload:
62
+ normalized_prefill = _normalize_token_ids(payload.get("prompt_token_ids"))
63
+ if normalized_prefill is not None:
64
+ self._prefill_token_ids = normalized_prefill
65
+
66
+ choices = payload.get("choices") or []
67
+ if not isinstance(choices, list):
68
+ return
69
+ for raw_choice in choices:
70
+ if not isinstance(raw_choice, dict):
71
+ continue
72
+ normalized_decode = _normalize_token_ids(raw_choice.get("token_ids"))
73
+ if normalized_decode:
74
+ self._decode_token_ids.extend(normalized_decode)
75
+
76
+ def flush(self) -> 'None':
77
+ if self._closed:
78
+ return
79
+ self._closed = True
80
+ record = {
81
+ "tokens": {
82
+ "prefill": list(self._prefill_token_ids or []),
83
+ "decode": list(self._decode_token_ids),
84
+ },
85
+ "send_timestamp": self._send_timestamp,
86
+ }
87
+ try:
88
+ self._writer._append_record(record)
89
+ except Exception as exc:
90
+ print(
91
+ "responses_server: failed to append PYCODEX_DUMP trajectory: %s"
92
+ % exc,
93
+ file=sys.stderr,
94
+ )
95
+
96
+
97
+ def _normalize_token_ids(raw_value: 'object') -> 'typing.Union[typing.List[int], None]':
98
+ if not isinstance(raw_value, list):
99
+ return None
100
+ token_ids = []
101
+ for value in raw_value:
102
+ if isinstance(value, bool) or not isinstance(value, int):
103
+ continue
104
+ token_ids.append(value)
105
+ return token_ids
@@ -102,20 +102,31 @@ class RunningFastAPITestServer:
102
102
  raise RuntimeError("timed out waiting for fake FastAPI server to stop")
103
103
 
104
104
 
105
- def build_text_chunks(text: 'str', model_id: 'str' = DEFAULT_MODEL_ID) -> 'typing.List[typing.Dict[str, object]]':
105
+ def build_text_chunks(
106
+ text: 'str',
107
+ model_id: 'str' = DEFAULT_MODEL_ID,
108
+ prompt_token_ids: 'typing.Union[typing.List[int], None]' = None,
109
+ decode_token_ids: 'typing.Union[typing.List[int], None]' = None,
110
+ ) -> 'typing.List[typing.Dict[str, object]]':
111
+ first_chunk: 'typing.Dict[str, object]' = {
112
+ "id": "chatcmpl_mock",
113
+ "object": "chat.completion.chunk",
114
+ "model": model_id,
115
+ "choices": [
116
+ {
117
+ "index": 0,
118
+ "delta": {"role": "assistant", "content": text},
119
+ "finish_reason": None,
120
+ }
121
+ ],
122
+ }
123
+ if prompt_token_ids is not None:
124
+ first_chunk["prompt_token_ids"] = list(prompt_token_ids)
125
+ if decode_token_ids is not None:
126
+ first_chunk["choices"][0]["token_ids"] = list(decode_token_ids)
127
+
106
128
  return [
107
- {
108
- "id": "chatcmpl_mock",
109
- "object": "chat.completion.chunk",
110
- "model": model_id,
111
- "choices": [
112
- {
113
- "index": 0,
114
- "delta": {"role": "assistant", "content": text},
115
- "finish_reason": None,
116
- }
117
- ],
118
- },
129
+ first_chunk,
119
130
  {
120
131
  "id": "chatcmpl_mock",
121
132
  "object": "chat.completion.chunk",
@@ -82,6 +82,80 @@ def test_responses_server_streams_text_from_chat_backend(tmp_path) -> 'None':
82
82
  ]
83
83
 
84
84
 
85
+ def test_responses_server_dumps_forwarded_chat_token_trajectory(
86
+ tmp_path,
87
+ monkeypatch,
88
+ ) -> 'None':
89
+ dump_root = tmp_path / "dump"
90
+ monkeypatch.setenv("PYCODEX_DUMP", str(dump_root))
91
+ capture_store = CaptureStore(tmp_path / "chat_capture")
92
+ fake_chat_server = build_fake_chat_server(
93
+ capture_store,
94
+ build_text_chunks(
95
+ "Hello",
96
+ prompt_token_ids=[101, 102, 103],
97
+ decode_token_ids=[201, 202],
98
+ ),
99
+ )
100
+ fake_chat_server.start()
101
+
102
+ app = ManagedResponseServer.build_app(
103
+ CompatServerConfig(
104
+ outcomming_base_url=f"http://127.0.0.1:{fake_chat_server.server_port}/v1",
105
+ )
106
+ )
107
+
108
+ try:
109
+ with TestClient(app) as client:
110
+ response = client.post(
111
+ "/v1/responses",
112
+ json={
113
+ "model": "gpt-5.4",
114
+ "instructions": "Be concise.",
115
+ "input": [
116
+ {
117
+ "type": "message",
118
+ "role": "user",
119
+ "content": [{"type": "input_text", "text": "hi"}],
120
+ }
121
+ ],
122
+ "tools": [],
123
+ "tool_choice": "auto",
124
+ "parallel_tool_calls": True,
125
+ "stream": True,
126
+ },
127
+ headers={"Accept": "text/event-stream"},
128
+ )
129
+ status = response.status_code
130
+ finally:
131
+ fake_chat_server.stop()
132
+
133
+ assert status == 200
134
+
135
+ request_files = sorted((tmp_path / "chat_capture").glob("*_POST_*.json"))
136
+ assert len(request_files) == 1
137
+ request = json.loads(request_files[0].read_text())
138
+ assert request["body"]["return_token_ids"] is True
139
+
140
+ dump_file = dump_root / "dump.jsonl"
141
+ assert dump_file.exists()
142
+ dump_records = [
143
+ json.loads(line)
144
+ for line in dump_file.read_text().splitlines()
145
+ if line.strip()
146
+ ]
147
+ assert dump_records == [
148
+ {
149
+ "tokens": {
150
+ "prefill": [101, 102, 103],
151
+ "decode": [201, 202],
152
+ },
153
+ "send_timestamp": dump_records[0]["send_timestamp"],
154
+ }
155
+ ]
156
+ assert isinstance(dump_records[0]["send_timestamp"], float)
157
+
158
+
85
159
  def test_responses_server_streams_text_from_messages_backend(tmp_path) -> 'None':
86
160
  capture_store = CaptureStore(tmp_path / "messages_capture")
87
161
  fake_messages_server = build_fake_messages_server(
@@ -1547,6 +1621,130 @@ def test_responses_server_mocks_web_search_and_continues_chat(tmp_path) -> 'None
1547
1621
  }
1548
1622
 
1549
1623
 
1624
+ def test_responses_server_dumps_all_forwarded_requests_for_mock_web_search(
1625
+ tmp_path,
1626
+ monkeypatch,
1627
+ ) -> 'None':
1628
+ dump_root = tmp_path / "dump"
1629
+ monkeypatch.setenv("PYCODEX_DUMP", str(dump_root))
1630
+ capture_store = CaptureStore(tmp_path / "chat_capture")
1631
+ fake_chat_server = build_fake_chat_server(
1632
+ capture_store,
1633
+ [
1634
+ [
1635
+ {
1636
+ "id": "chatcmpl_mock",
1637
+ "object": "chat.completion.chunk",
1638
+ "model": "gpt-5.4",
1639
+ "prompt_token_ids": [11, 12],
1640
+ "choices": [
1641
+ {
1642
+ "index": 0,
1643
+ "delta": {
1644
+ "tool_calls": [
1645
+ {
1646
+ "index": 0,
1647
+ "id": "ws_1",
1648
+ "type": "function",
1649
+ "function": {
1650
+ "arguments": '{"query":"github codex"}'
1651
+ },
1652
+ }
1653
+ ]
1654
+ },
1655
+ "token_ids": [21, 22],
1656
+ "finish_reason": None,
1657
+ }
1658
+ ],
1659
+ },
1660
+ {
1661
+ "id": "chatcmpl_mock",
1662
+ "object": "chat.completion.chunk",
1663
+ "model": "gpt-5.4",
1664
+ "choices": [
1665
+ {
1666
+ "index": 0,
1667
+ "delta": {},
1668
+ "finish_reason": "tool_calls",
1669
+ }
1670
+ ],
1671
+ },
1672
+ ],
1673
+ build_text_chunks(
1674
+ "done",
1675
+ prompt_token_ids=[31, 32, 33],
1676
+ decode_token_ids=[41, 42],
1677
+ ),
1678
+ ],
1679
+ )
1680
+ fake_chat_server.start()
1681
+
1682
+ app = ManagedResponseServer.build_app(
1683
+ CompatServerConfig(
1684
+ outcomming_base_url=f"http://127.0.0.1:{fake_chat_server.server_port}/v1",
1685
+ )
1686
+ )
1687
+
1688
+ try:
1689
+ with TestClient(app) as client:
1690
+ response = client.post(
1691
+ "/v1/responses",
1692
+ json={
1693
+ "model": "gpt-5.4",
1694
+ "instructions": "Be concise.",
1695
+ "input": [
1696
+ {
1697
+ "type": "message",
1698
+ "role": "user",
1699
+ "content": [
1700
+ {
1701
+ "type": "input_text",
1702
+ "text": "Search the web, then answer.",
1703
+ }
1704
+ ],
1705
+ }
1706
+ ],
1707
+ "tools": [
1708
+ {
1709
+ "type": "web_search",
1710
+ "external_web_access": True,
1711
+ }
1712
+ ],
1713
+ "tool_choice": "auto",
1714
+ "parallel_tool_calls": False,
1715
+ "stream": True,
1716
+ },
1717
+ headers={"Accept": "text/event-stream"},
1718
+ )
1719
+ status = response.status_code
1720
+ finally:
1721
+ fake_chat_server.stop()
1722
+
1723
+ assert status == 200
1724
+
1725
+ request_files = sorted((tmp_path / "chat_capture").glob("*_POST_*.json"))
1726
+ assert len(request_files) == 2
1727
+ for request_file in request_files:
1728
+ request = json.loads(request_file.read_text())
1729
+ assert request["body"]["return_token_ids"] is True
1730
+
1731
+ dump_records = [
1732
+ json.loads(line)
1733
+ for line in (dump_root / "dump.jsonl").read_text().splitlines()
1734
+ if line.strip()
1735
+ ]
1736
+ assert len(dump_records) == 2
1737
+ assert dump_records[0]["tokens"] == {
1738
+ "prefill": [11, 12],
1739
+ "decode": [21, 22],
1740
+ }
1741
+ assert dump_records[1]["tokens"] == {
1742
+ "prefill": [31, 32, 33],
1743
+ "decode": [41, 42],
1744
+ }
1745
+ assert dump_records[0]["send_timestamp"] <= dump_records[1]["send_timestamp"]
1746
+
1747
+
1550
1748
  def test_responses_server_turns_mock_web_search_calls_into_messages_followup(
1551
1749
  tmp_path,
1552
1750
  ) -> 'None':
@@ -1794,3 +1992,12 @@ def test_responses_server_turns_truncated_downstream_stream_into_response_failed
1794
1992
  or "outcomming chat stream ended before [DONE]" in body
1795
1993
  )
1796
1994
  assert "event: response.completed" not in body
1995
+
1996
+
1997
+ def test_managed_response_server_forces_asyncio_loop() -> 'None':
1998
+ server = ManagedResponseServer(
1999
+ CompatServerConfig(
2000
+ outcomming_base_url="http://127.0.0.1:8000/v1",
2001
+ )
2002
+ )
2003
+ assert server._uvicorn_config.loop == "asyncio"
@@ -773,6 +773,73 @@ async def test_run_cli_bootstraps_called_home_before_loading_config(
773
773
  assert captured["provider_config"].api_key_env == "PORTABLE_API_KEY"
774
774
 
775
775
 
776
+ @pytest.mark.asyncio
777
+ async def test_run_cli_call_reads_called_home_text_as_utf8(
778
+ tmp_path,
779
+ monkeypatch,
780
+ ) -> 'None':
781
+ codex_home = tmp_path / "codex-home"
782
+ codex_home.mkdir()
783
+ _write_stored_codex_home(codex_home)
784
+ (codex_home / "AGENTS.md").write_text(
785
+ "stored rules with unicode: \u22645 min \u2014 \u4e2d\u6587\n",
786
+ encoding="utf-8",
787
+ )
788
+ (codex_home / "skills" / "demo" / "SKILL.md").write_text(
789
+ "---\n"
790
+ "name: demo\n"
791
+ "description: unicode \u2264 \u2014 \u4e2d\u6587\n"
792
+ "---\n"
793
+ "Stored skill.\n",
794
+ encoding="utf-8",
795
+ )
796
+ server = CodexStorageServer(tmp_path / "storage-server", port=0)
797
+ server.start()
798
+
799
+ original_read_text = Path.read_text
800
+
801
+ def read_text_as_gbk_by_default(path, *args, **kwargs):
802
+ encoding = kwargs.get("encoding")
803
+ if args:
804
+ encoding = args[0]
805
+ if encoding is None:
806
+ return path.read_bytes().decode("gbk")
807
+ return original_read_text(path, *args, **kwargs)
808
+
809
+ class _FakeResponsesModelClient(_ScriptedResponsesClient):
810
+ def __init__(
811
+ self,
812
+ config,
813
+ timeout_seconds,
814
+ session_id=None,
815
+ originator=None,
816
+ user_agent=None,
817
+ openai_subagent=None,
818
+ ) -> 'None':
819
+ del timeout_seconds, user_agent, openai_subagent
820
+ super().__init__([ModelResponse(items=[AssistantMessage(text="OK")])])
821
+ self._config = config
822
+ self.model = config.model
823
+ self._session_id = session_id
824
+ self._originator = originator or "pycodex"
825
+
826
+ monkeypatch.setattr(Path, "read_text", read_text_as_gbk_by_default)
827
+ monkeypatch.setattr("pycodex.cli.ResponsesModelClient", _FakeResponsesModelClient)
828
+ monkeypatch.setattr("pycodex.cli.configure_loguru", lambda: None)
829
+ monkeypatch.setattr("sys.stdin.read", lambda: "")
830
+ monkeypatch.delenv("CODEX_HOME", raising=False)
831
+ monkeypatch.delenv("PORTABLE_API_KEY", raising=False)
832
+
833
+ try:
834
+ stored_call = upload_codex_home(f"{codex_home}@{server.server_address}")
835
+ args = build_parser().parse_args(["--call", stored_call, "say ok"])
836
+ exit_code = await run_cli(args)
837
+ finally:
838
+ server.stop()
839
+
840
+ assert exit_code == 0
841
+
842
+
776
843
  def test_get_tools_registers_expected_builtin_tools() -> 'None':
777
844
  registry = get_tools()
778
845
  assert registry.names() == (
File without changes
File without changes
File without changes
File without changes