tau-coding-agent 0.1.0__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 (283) hide show
  1. tau/__init__.py +0 -0
  2. tau/agent/__init__.py +11 -0
  3. tau/agent/prompt/__init__.py +10 -0
  4. tau/agent/prompt/builder.py +302 -0
  5. tau/agent/prompt/types.py +33 -0
  6. tau/agent/service.py +369 -0
  7. tau/agent/types.py +61 -0
  8. tau/auth/manager.py +247 -0
  9. tau/auth/storage.py +82 -0
  10. tau/auth/types.py +41 -0
  11. tau/builtins/__init__.py +4 -0
  12. tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
  13. tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
  14. tau/builtins/commands/__init__.py +41 -0
  15. tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
  16. tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
  17. tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
  18. tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
  19. tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
  20. tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
  21. tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
  22. tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
  23. tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
  24. tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
  25. tau/builtins/commands/clear.py +16 -0
  26. tau/builtins/commands/compact.py +28 -0
  27. tau/builtins/commands/reload.py +27 -0
  28. tau/builtins/commands/session.py +19 -0
  29. tau/builtins/extensions/footer/__init__.py +76 -0
  30. tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
  31. tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
  32. tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
  33. tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
  34. tau/builtins/extensions/footer/git.py +26 -0
  35. tau/builtins/extensions/footer/model.py +69 -0
  36. tau/builtins/extensions/footer/utils.py +44 -0
  37. tau/builtins/extensions/header/__init__.py +18 -0
  38. tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
  39. tau/builtins/models/__init__.py +0 -0
  40. tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
  41. tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
  42. tau/builtins/models/audio.py +43 -0
  43. tau/builtins/models/image.py +43 -0
  44. tau/builtins/models/text.py +482 -0
  45. tau/builtins/models/video.py +40 -0
  46. tau/builtins/prompts/commit.md +7 -0
  47. tau/builtins/prompts/docs.md +7 -0
  48. tau/builtins/prompts/explain.md +7 -0
  49. tau/builtins/prompts/fix.md +7 -0
  50. tau/builtins/prompts/refactor.md +7 -0
  51. tau/builtins/prompts/review.md +7 -0
  52. tau/builtins/prompts/test.md +7 -0
  53. tau/builtins/providers/__init__.py +0 -0
  54. tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  55. tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
  56. tau/builtins/providers/audio.py +10 -0
  57. tau/builtins/providers/image.py +9 -0
  58. tau/builtins/providers/text.py +33 -0
  59. tau/builtins/providers/video.py +6 -0
  60. tau/builtins/skills/code-review/SKILL.md +4 -0
  61. tau/builtins/skills/debug/SKILL.md +4 -0
  62. tau/builtins/skills/git-commit/SKILL.md +4 -0
  63. tau/builtins/themes/dark.yaml +1 -0
  64. tau/builtins/themes/light.yaml +46 -0
  65. tau/builtins/tools/__init__.py +73 -0
  66. tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  67. tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  68. tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
  69. tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
  70. tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
  71. tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
  72. tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
  73. tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
  74. tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
  75. tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
  76. tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
  77. tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
  78. tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
  79. tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
  80. tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
  81. tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
  82. tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
  83. tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
  84. tau/builtins/tools/edit.py +215 -0
  85. tau/builtins/tools/glob.py +112 -0
  86. tau/builtins/tools/grep.py +146 -0
  87. tau/builtins/tools/ls.py +135 -0
  88. tau/builtins/tools/read.py +122 -0
  89. tau/builtins/tools/terminal.py +150 -0
  90. tau/builtins/tools/write.py +105 -0
  91. tau/commands/__init__.py +10 -0
  92. tau/commands/registry.py +71 -0
  93. tau/commands/types.py +33 -0
  94. tau/console/__init__.py +0 -0
  95. tau/console/cli.py +266 -0
  96. tau/console/commands/__init__.py +0 -0
  97. tau/console/commands/auth.py +193 -0
  98. tau/console/commands/packages.py +104 -0
  99. tau/console/commands/update.py +76 -0
  100. tau/core/__init__.py +0 -0
  101. tau/core/registry.py +102 -0
  102. tau/engine/__init__.py +47 -0
  103. tau/engine/service.py +768 -0
  104. tau/engine/types.py +163 -0
  105. tau/extensions/__init__.py +28 -0
  106. tau/extensions/api.py +928 -0
  107. tau/extensions/context.py +462 -0
  108. tau/extensions/events.py +70 -0
  109. tau/extensions/loader.py +386 -0
  110. tau/extensions/runtime.py +184 -0
  111. tau/extensions/settings.py +137 -0
  112. tau/hooks/__init__.py +112 -0
  113. tau/hooks/engine.py +237 -0
  114. tau/hooks/inference.py +21 -0
  115. tau/hooks/runtime.py +126 -0
  116. tau/hooks/service.py +121 -0
  117. tau/hooks/session.py +117 -0
  118. tau/hooks/tui.py +61 -0
  119. tau/hooks/types.py +72 -0
  120. tau/inference/__init__.py +180 -0
  121. tau/inference/api/__init__.py +0 -0
  122. tau/inference/api/audio/__init__.py +0 -0
  123. tau/inference/api/audio/base.py +29 -0
  124. tau/inference/api/audio/builtins.py +15 -0
  125. tau/inference/api/audio/elevenlabs_audio.py +183 -0
  126. tau/inference/api/audio/gemini_audio.py +95 -0
  127. tau/inference/api/audio/openai_audio.py +159 -0
  128. tau/inference/api/audio/registry.py +15 -0
  129. tau/inference/api/audio/sarvam_audio.py +163 -0
  130. tau/inference/api/audio/service.py +103 -0
  131. tau/inference/api/audio/utils.py +47 -0
  132. tau/inference/api/image/__init__.py +0 -0
  133. tau/inference/api/image/base.py +17 -0
  134. tau/inference/api/image/builtins.py +8 -0
  135. tau/inference/api/image/gemini_image.py +77 -0
  136. tau/inference/api/image/openai_image.py +103 -0
  137. tau/inference/api/image/openrouter.py +144 -0
  138. tau/inference/api/image/registry.py +15 -0
  139. tau/inference/api/image/service.py +71 -0
  140. tau/inference/api/registry.py +82 -0
  141. tau/inference/api/text/__init__.py +0 -0
  142. tau/inference/api/text/anthropic_claude_code.py +222 -0
  143. tau/inference/api/text/anthropic_messages.py +196 -0
  144. tau/inference/api/text/base.py +40 -0
  145. tau/inference/api/text/builtins.py +19 -0
  146. tau/inference/api/text/gemini_generate.py +234 -0
  147. tau/inference/api/text/github_copilot_chat.py +172 -0
  148. tau/inference/api/text/google_antigravity.py +522 -0
  149. tau/inference/api/text/mistral_chat.py +284 -0
  150. tau/inference/api/text/ollama_chat.py +200 -0
  151. tau/inference/api/text/openai_codex_responses.py +497 -0
  152. tau/inference/api/text/openai_completions.py +227 -0
  153. tau/inference/api/text/openai_responses.py +235 -0
  154. tau/inference/api/text/registry.py +50 -0
  155. tau/inference/api/text/service.py +297 -0
  156. tau/inference/api/text/types.py +7 -0
  157. tau/inference/api/text/utils.py +228 -0
  158. tau/inference/api/video/__init__.py +0 -0
  159. tau/inference/api/video/base.py +26 -0
  160. tau/inference/api/video/builtins.py +7 -0
  161. tau/inference/api/video/fal_video.py +119 -0
  162. tau/inference/api/video/openrouter_video.py +142 -0
  163. tau/inference/api/video/registry.py +15 -0
  164. tau/inference/api/video/service.py +72 -0
  165. tau/inference/model/__init__.py +0 -0
  166. tau/inference/model/registry.py +102 -0
  167. tau/inference/model/types.py +65 -0
  168. tau/inference/provider/__init__.py +0 -0
  169. tau/inference/provider/oauth/__init__.py +35 -0
  170. tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
  171. tau/inference/provider/oauth/github_copilot.py +333 -0
  172. tau/inference/provider/oauth/google_antigravity.py +258 -0
  173. tau/inference/provider/oauth/openai_codex.py +309 -0
  174. tau/inference/provider/oauth/pkce.py +14 -0
  175. tau/inference/provider/oauth/types.py +46 -0
  176. tau/inference/provider/oauth/utils.py +154 -0
  177. tau/inference/provider/registry.py +141 -0
  178. tau/inference/provider/types.py +114 -0
  179. tau/inference/types.py +549 -0
  180. tau/inference/utils.py +219 -0
  181. tau/message/__init__.py +0 -0
  182. tau/message/types.py +482 -0
  183. tau/message/utils.py +178 -0
  184. tau/packages/__init__.py +11 -0
  185. tau/packages/manager.py +190 -0
  186. tau/packages/types.py +20 -0
  187. tau/packages/utils.py +67 -0
  188. tau/prompts/expand.py +58 -0
  189. tau/prompts/loader.py +69 -0
  190. tau/prompts/registry.py +45 -0
  191. tau/prompts/types.py +24 -0
  192. tau/rpc/__init__.py +8 -0
  193. tau/rpc/mode.py +783 -0
  194. tau/rpc/types.py +252 -0
  195. tau/runtime/service.py +759 -0
  196. tau/runtime/types.py +303 -0
  197. tau/session/branch_summarization.py +312 -0
  198. tau/session/compaction.py +646 -0
  199. tau/session/manager.py +652 -0
  200. tau/session/types.py +188 -0
  201. tau/session/utils.py +233 -0
  202. tau/settings/manager.py +1077 -0
  203. tau/settings/paths.py +150 -0
  204. tau/settings/storage.py +63 -0
  205. tau/settings/types.py +173 -0
  206. tau/settings/utils.py +25 -0
  207. tau/skills/loader.py +91 -0
  208. tau/skills/registry.py +70 -0
  209. tau/skills/types.py +25 -0
  210. tau/themes/loader.py +238 -0
  211. tau/themes/registry.py +108 -0
  212. tau/themes/types.py +19 -0
  213. tau/tool/__init__.py +3 -0
  214. tau/tool/registry.py +117 -0
  215. tau/tool/render.py +21 -0
  216. tau/tool/types.py +244 -0
  217. tau/trust/__init__.py +13 -0
  218. tau/trust/manager.py +80 -0
  219. tau/trust/types.py +14 -0
  220. tau/trust/utils.py +72 -0
  221. tau/tui/__init__.py +54 -0
  222. tau/tui/agent_hooks.py +346 -0
  223. tau/tui/ansi.py +330 -0
  224. tau/tui/app.py +540 -0
  225. tau/tui/autocomplete.py +33 -0
  226. tau/tui/capabilities.py +119 -0
  227. tau/tui/commands/__init__.py +3 -0
  228. tau/tui/commands/appearance.py +498 -0
  229. tau/tui/commands/auth.py +232 -0
  230. tau/tui/commands/context.py +38 -0
  231. tau/tui/commands/misc.py +82 -0
  232. tau/tui/commands/model.py +118 -0
  233. tau/tui/commands/session.py +464 -0
  234. tau/tui/component.py +268 -0
  235. tau/tui/components/__init__.py +0 -0
  236. tau/tui/components/autocomplete_manager.py +267 -0
  237. tau/tui/components/autocomplete_picker.py +143 -0
  238. tau/tui/components/box.py +90 -0
  239. tau/tui/components/command_palette.py +144 -0
  240. tau/tui/components/dynamic_border.py +19 -0
  241. tau/tui/components/file_picker.py +233 -0
  242. tau/tui/components/image.py +181 -0
  243. tau/tui/components/inline_selector.py +71 -0
  244. tau/tui/components/layout.py +1194 -0
  245. tau/tui/components/message_list.py +692 -0
  246. tau/tui/components/modal.py +97 -0
  247. tau/tui/components/model_palette.py +204 -0
  248. tau/tui/components/picker_overlay.py +174 -0
  249. tau/tui/components/prompt_overlay.py +236 -0
  250. tau/tui/components/resume_modal.py +372 -0
  251. tau/tui/components/select_list.py +222 -0
  252. tau/tui/components/settings_modal.py +274 -0
  253. tau/tui/components/settings_schema.py +203 -0
  254. tau/tui/components/spinner.py +119 -0
  255. tau/tui/components/text_input.py +396 -0
  256. tau/tui/components/text_prompt.py +82 -0
  257. tau/tui/components/tree_select_list.py +580 -0
  258. tau/tui/components/trust_screen.py +97 -0
  259. tau/tui/diff.py +114 -0
  260. tau/tui/fuzzy.py +99 -0
  261. tau/tui/input.py +496 -0
  262. tau/tui/input_handler.py +716 -0
  263. tau/tui/keybindings.py +87 -0
  264. tau/tui/markdown.py +286 -0
  265. tau/tui/message_renderers.py +31 -0
  266. tau/tui/overlay.py +326 -0
  267. tau/tui/renderer.py +378 -0
  268. tau/tui/terminal.py +499 -0
  269. tau/tui/theme.py +148 -0
  270. tau/tui/tui.py +544 -0
  271. tau/tui/ui_context.py +768 -0
  272. tau/tui/utils.py +20 -0
  273. tau/utils/__init__.py +0 -0
  274. tau/utils/http_proxy.py +221 -0
  275. tau/utils/image_processing.py +172 -0
  276. tau/utils/secrets.py +59 -0
  277. tau/utils/version_check.py +60 -0
  278. tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
  279. tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
  280. tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
  281. tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  282. tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  283. tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,497 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+ import base64
4
+ import json
5
+ from tau.inference.api.text.utils import parse_tool_args
6
+ import re
7
+ from collections.abc import AsyncGenerator, AsyncIterator
8
+ from contextlib import aclosing
9
+ from typing import Any
10
+
11
+ import httpx
12
+ import websockets
13
+ import websockets.asyncio.client
14
+
15
+ from tau.inference.api.text.base import BaseLLMAPI as BaseAPI
16
+ from tau.inference.api.text.types import APIResponse
17
+ from tau.inference.model.types import Model
18
+ from tau.inference.types import (
19
+ LLMContext, LLMEvent, LLMOptions, StopReason, ThinkingLevel, Transport,
20
+ StartEvent, EndEvent, ErrorEvent,
21
+ TextStartEvent, TextDeltaEvent, TextEndEvent,
22
+ ThinkingStartEvent, ThinkingDeltaEvent, ThinkingEndEvent,
23
+ ToolCallStartEvent, ToolCallDeltaEvent, ToolCallEndEvent,
24
+ normalize_structured_response_format,
25
+ )
26
+ from tau.message.types import (
27
+ SystemMessage, UserMessage, AssistantMessage, ToolMessage,
28
+ TextContent, ImageContent, ThinkingContent, ToolCallContent, ToolResultContent,
29
+ )
30
+ from typing import Optional, TYPE_CHECKING
31
+ if TYPE_CHECKING:
32
+ from tau.tool.types import Tool
33
+
34
+ _DEFAULT_BASE_URL = "https://chatgpt.com/backend-api"
35
+ _JWT_CLAIM_PATH = "https://api.openai.com/auth"
36
+ _MAX_RETRIES = 3
37
+ _BASE_DELAY_S = 1.0
38
+ _RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
39
+ _RETRYABLE_RE = re.compile(
40
+ r"rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused", re.I
41
+ )
42
+ _COMPLETION_TYPES = {"response.done", "response.completed", "response.incomplete"}
43
+
44
+ _THINKING_EFFORT: dict[ThinkingLevel, str] = {
45
+ ThinkingLevel.Low: "low",
46
+ ThinkingLevel.Minimal: "low",
47
+ ThinkingLevel.Medium: "medium",
48
+ ThinkingLevel.High: "high",
49
+ ThinkingLevel.XHigh: "high",
50
+ ThinkingLevel.Max: "high",
51
+ }
52
+
53
+ _STOP_REASON: dict[str, StopReason] = {
54
+ "stop": StopReason.Stop,
55
+ "max_output_tokens": StopReason.Length,
56
+ "tool_calls": StopReason.ToolCalls,
57
+ "content_filter": StopReason.ContentFilter,
58
+ }
59
+
60
+
61
+ # ── Auth ──────────────────────────────────────────────────────────────────────
62
+
63
+ def _extract_account_id(token: str) -> str:
64
+ try:
65
+ parts = token.split(".")
66
+ if len(parts) != 3:
67
+ raise ValueError("not a JWT")
68
+ padding = (4 - len(parts[1]) % 4) % 4
69
+ payload = json.loads(base64.urlsafe_b64decode(parts[1] + "=" * padding))
70
+ account_id = payload.get(_JWT_CLAIM_PATH, {}).get("chatgpt_account_id")
71
+ if not account_id:
72
+ raise ValueError("missing chatgpt_account_id")
73
+ return account_id
74
+ except Exception as exc:
75
+ raise ValueError(f"Failed to extract account_id from token: {exc}") from exc
76
+
77
+
78
+ # ── URL ───────────────────────────────────────────────────────────────────────
79
+
80
+ def _resolve_http_url(base_url: str | None) -> str:
81
+ raw = (base_url or _DEFAULT_BASE_URL).rstrip("/")
82
+ if raw.endswith("/codex/responses"):
83
+ return raw
84
+ if raw.endswith("/codex"):
85
+ return f"{raw}/responses"
86
+ return f"{raw}/codex/responses"
87
+
88
+
89
+ def _resolve_ws_url(base_url: str | None) -> str:
90
+ http = _resolve_http_url(base_url)
91
+ return http.replace("https://", "wss://", 1).replace("http://", "ws://", 1)
92
+
93
+
94
+ # ── Message → input conversion ────────────────────────────────────────────────
95
+
96
+ def _content_to_input(content_items: list, role: str) -> list[dict[str, Any]]:
97
+ # The Responses API requires assistant text parts to be "output_text"
98
+ # ("input_text" is only valid for user/input content).
99
+ text_type = "output_text" if role == "assistant" else "input_text"
100
+ parts: list[dict[str, Any]] = []
101
+ for item in content_items:
102
+ match item:
103
+ case TextContent():
104
+ parts.append({"type": text_type, "text": item.content})
105
+ case ImageContent():
106
+ for b64, mime in item.to_base64():
107
+ url = b64 if b64.startswith("http") else f"data:{mime or 'image/png'};base64,{b64}"
108
+ parts.append({"type": "input_image", "image_url": url})
109
+ return parts
110
+
111
+
112
+ def _messages_to_input(messages: list[LLMMessage]) -> tuple[str, list[dict[str, Any]]]:
113
+ instructions = "You are a helpful assistant."
114
+ input_items: list[dict[str, Any]] = []
115
+
116
+ for msg in messages:
117
+ match msg:
118
+ case SystemMessage():
119
+ text = "\n".join(c.content for c in msg.contents if isinstance(c, TextContent))
120
+ if text:
121
+ instructions = text
122
+ case ToolMessage():
123
+ for content in msg.contents:
124
+ if isinstance(content, ToolResultContent):
125
+ input_items.append({
126
+ "type": "function_call_output",
127
+ "call_id": content.id,
128
+ "output": content.content,
129
+ })
130
+ case UserMessage() | AssistantMessage():
131
+ role = "user" if isinstance(msg, UserMessage) else "assistant"
132
+ parts = _content_to_input(msg.contents, role)
133
+ if parts:
134
+ input_items.append({"role": role, "content": parts})
135
+ # function_call is a top-level input item in the Responses API,
136
+ # not nested inside a message's content array. It must precede
137
+ # its matching function_call_output (emitted by the ToolMessage).
138
+ for content in msg.contents:
139
+ if isinstance(content, ToolCallContent):
140
+ input_items.append({
141
+ "type": "function_call",
142
+ "call_id": content.id,
143
+ "name": content.name,
144
+ "arguments": json.dumps(content.args),
145
+ })
146
+
147
+ return instructions, input_items
148
+
149
+
150
+ def _text_format(response_format: Any | None) -> dict[str, Any] | None:
151
+ structured = normalize_structured_response_format(response_format)
152
+ if structured is None:
153
+ return None
154
+ return {
155
+ "format": {
156
+ "type": "json_schema",
157
+ "name": structured.name,
158
+ "schema": structured.schema,
159
+ "strict": structured.strict,
160
+ }
161
+ }
162
+
163
+
164
+ # ── Request building ──────────────────────────────────────────────────────────
165
+
166
+ def _build_body(
167
+ model: Model,
168
+ instructions: str,
169
+ input_items: list[dict[str, Any]],
170
+ options: LLMOptions,
171
+ tools: Optional[list[Tool]] = None,
172
+ ) -> dict[str, Any]:
173
+ effort = _THINKING_EFFORT.get(options.thinking_level, "medium") if options.thinking_level else "medium"
174
+ body: dict[str, Any] = {
175
+ "model": model.id,
176
+ "store": False,
177
+ "stream": True,
178
+ "instructions": instructions,
179
+ "input": input_items,
180
+ "text": {"verbosity": "medium"},
181
+ "include": ["reasoning.encrypted_content"],
182
+ "reasoning": {"effort": effort, "summary": "auto"},
183
+ }
184
+ # NOTE: the ChatGPT Codex backend rejects `max_output_tokens`
185
+ # ("Unsupported parameter") — unlike the standard OpenAI Responses API.
186
+ # Output length is governed by the subscription, so we never send it.
187
+
188
+ if tools:
189
+ body["tools"] = [
190
+ {
191
+ "type": "function",
192
+ "name": tool.name,
193
+ "description": tool.description,
194
+ "parameters": tool.schema.model_json_schema(),
195
+ }
196
+ for tool in tools
197
+ ]
198
+ return body
199
+
200
+
201
+ def _build_headers(token: str, account_id: str, *, websocket: bool = False) -> dict[str, str]:
202
+ headers: dict[str, str] = {
203
+ "Authorization": f"Bearer {token}",
204
+ "chatgpt-account-id": account_id,
205
+ "originator": "codex_cli_rs",
206
+ }
207
+ if websocket:
208
+ headers["OpenAI-Beta"] = "responses_websockets=2026-02-06"
209
+ else:
210
+ headers["OpenAI-Beta"] = "responses=experimental"
211
+ headers["accept"] = "text/event-stream"
212
+ headers["content-type"] = "application/json"
213
+ return headers
214
+
215
+
216
+ # ── Codex event normalization ─────────────────────────────────────────────────
217
+
218
+ async def _map_codex_events(
219
+ raw: AsyncIterator[dict[str, Any]],
220
+ ) -> AsyncIterator[dict[str, Any]]:
221
+ async for event in raw:
222
+ etype = event.get("type", "")
223
+
224
+ if etype == "error":
225
+ code = event.get("code", "")
226
+ message = event.get("message", "") or code or json.dumps(event)
227
+ raise RuntimeError(f"Codex error: {message}")
228
+
229
+ if etype == "response.failed":
230
+ err = (event.get("response") or {}).get("error") or {}
231
+ raise RuntimeError(err.get("message") or "Codex response failed")
232
+
233
+ if etype in _COMPLETION_TYPES:
234
+ yield {**event, "type": "response.completed", "response": event.get("response") or {}}
235
+ return
236
+
237
+ yield event
238
+
239
+
240
+ # ── SSE parsing ───────────────────────────────────────────────────────────────
241
+
242
+ async def _parse_sse(response: httpx.Response) -> AsyncIterator[dict[str, Any]]:
243
+ buffer = ""
244
+ async for chunk in response.aiter_text():
245
+ buffer += chunk
246
+ while "\n\n" in buffer:
247
+ block, buffer = buffer.split("\n\n", 1)
248
+ data_lines = [
249
+ line[5:].strip()
250
+ for line in block.splitlines()
251
+ if line.startswith("data:")
252
+ ]
253
+ if not data_lines:
254
+ continue
255
+ data = "\n".join(data_lines).strip()
256
+ if not data or data == "[DONE]":
257
+ continue
258
+ yield json.loads(data)
259
+
260
+
261
+ # ── WebSocket parsing ─────────────────────────────────────────────────────────
262
+
263
+ async def _parse_ws(ws: websockets.asyncio.client.ClientConnection) -> AsyncIterator[dict[str, Any]]:
264
+ saw_completion = False
265
+ async for raw in ws:
266
+ event: dict[str, Any] = json.loads(raw)
267
+ etype = event.get("type", "")
268
+ if etype in _COMPLETION_TYPES:
269
+ saw_completion = True
270
+ yield event
271
+ if saw_completion:
272
+ return
273
+ if not saw_completion:
274
+ raise RuntimeError("WebSocket stream closed before completion event")
275
+
276
+
277
+ # ── Retry helper ──────────────────────────────────────────────────────────────
278
+
279
+ def _is_retryable(status: int, body: str) -> bool:
280
+ return status in _RETRYABLE_STATUSES or bool(_RETRYABLE_RE.search(body))
281
+
282
+
283
+ # ── Event processing ──────────────────────────────────────────────────────────
284
+
285
+ async def _process_events(events: AsyncIterator[dict[str, Any]]) -> AsyncGenerator[LLMEvent, None]:
286
+ # The Responses API gives a function_call output item both an `id`
287
+ # (e.g. "fc_...") and a `call_id` (e.g. "call_..."). The subsequent
288
+ # function_call_arguments.delta/.done events reference the item by
289
+ # `item_id` (== item `id`), while the tool result must be paired by
290
+ # `call_id`. Map item id -> (call_id, name) to bridge the two.
291
+ call_id_by_item: dict[str, str] = {}
292
+ name_by_item: dict[str, str] = {}
293
+ # The Codex backend delivers a complete function_call as a single
294
+ # response.output_item.done (no streamed *.arguments.delta/.done events),
295
+ # while the standard streaming path emits them incrementally. Track which
296
+ # call_ids have already been started/ended so the two paths don't duplicate.
297
+ started_calls: set[str] = set()
298
+ ended_calls: set[str] = set()
299
+ saw_tool_call = False
300
+ _input_tokens = 0
301
+ _output_tokens = 0
302
+ _cache_read_tokens = 0
303
+
304
+ async for event in events:
305
+ etype = event.get("type", "")
306
+
307
+ if etype == "response.output_item.added":
308
+ item = event.get("item") or {}
309
+ itype = item.get("type", "")
310
+ if itype == "message":
311
+ yield TextStartEvent(text=TextContent(content=""))
312
+ elif itype == "reasoning":
313
+ yield ThinkingStartEvent(thinking=None)
314
+ elif itype == "function_call":
315
+ item_id = item.get("id", "")
316
+ call_id = item.get("call_id", "")
317
+ name = item.get("name", "")
318
+ saw_tool_call = True
319
+ call_id_by_item[item_id] = call_id
320
+ name_by_item[item_id] = name
321
+ started_calls.add(call_id)
322
+ yield ToolCallStartEvent(tool_call=ToolCallContent(id=call_id, name=name)
323
+ )
324
+
325
+ elif etype == "response.output_text.delta":
326
+ yield TextDeltaEvent(text=TextContent(content=event.get("delta", "")))
327
+
328
+ elif etype == "response.output_text.done":
329
+ yield TextEndEvent(text=TextContent(content=event.get("text", "")))
330
+
331
+ elif etype == "response.reasoning_summary_text.delta":
332
+ yield ThinkingDeltaEvent(thinking=ThinkingContent(content=event.get("delta", "")))
333
+
334
+ elif etype == "response.reasoning_summary_text.done":
335
+ yield ThinkingEndEvent(thinking=ThinkingContent(content=event.get("text", "")))
336
+
337
+ elif etype == "response.function_call_arguments.delta":
338
+ item_id = event.get("item_id", "")
339
+ yield ToolCallDeltaEvent(tool_call=ToolCallContent(
340
+ id=call_id_by_item.get(item_id, item_id)
341
+ )
342
+ )
343
+
344
+ elif etype == "response.function_call_arguments.done":
345
+ item_id = event.get("item_id", "")
346
+ args_str = event.get("arguments", "").strip()
347
+ args = parse_tool_args(args_str)
348
+
349
+ call_id = call_id_by_item.get(item_id, item_id)
350
+ saw_tool_call = True
351
+ ended_calls.add(call_id)
352
+ yield ToolCallEndEvent(tool_call=ToolCallContent(
353
+ id=call_id,
354
+ name=name_by_item.get(item_id, ""),
355
+ args=args
356
+ )
357
+ )
358
+
359
+ elif etype == "response.output_item.done":
360
+ item = event.get("item") or {}
361
+ if item.get("type") == "function_call":
362
+ call_id = item.get("call_id", "")
363
+ name = item.get("name", "")
364
+ saw_tool_call = True
365
+ if call_id not in ended_calls:
366
+ args_str = (item.get("arguments") or "").strip()
367
+ args = parse_tool_args(args_str)
368
+ if call_id not in started_calls:
369
+ started_calls.add(call_id)
370
+ yield ToolCallStartEvent(
371
+ tool_call=ToolCallContent(id=call_id, name=name)
372
+ )
373
+ ended_calls.add(call_id)
374
+ yield ToolCallEndEvent(tool_call=ToolCallContent(
375
+ id=call_id, name=name, args=args
376
+ )
377
+ )
378
+
379
+ elif etype == "response.completed":
380
+ response = event.get("response") or {}
381
+ usage = response.get("usage") or {}
382
+ _input_tokens = usage.get("input_tokens", 0) or 0
383
+ _output_tokens = usage.get("output_tokens", 0) or 0
384
+ _details = usage.get("prompt_tokens_details") or {}
385
+ _cache_read_tokens = _details.get("cached_tokens", 0) or 0
386
+ stop_reason = _STOP_REASON.get(response.get("stop_reason") or "", StopReason.Stop)
387
+ if saw_tool_call and stop_reason == StopReason.Stop:
388
+ stop_reason = StopReason.ToolCalls
389
+ yield EndEvent(reason=stop_reason, input_tokens=_input_tokens, output_tokens=_output_tokens, cache_read_tokens=_cache_read_tokens)
390
+
391
+
392
+ # ── API class ─────────────────────────────────────────────────────────────────
393
+
394
+ class OpenAICodexResponsesAPI(BaseAPI):
395
+ SUPPORTED_TRANSPORTS = (Transport.HTTP, Transport.WEBSOCKET)
396
+
397
+ def __init__(self, options: LLMOptions) -> None:
398
+ super().__init__(options)
399
+ self._http_url = _resolve_http_url(options.base_url)
400
+ self._ws_url = _resolve_ws_url(options.base_url)
401
+
402
+ async def _stream_sse(
403
+ self,
404
+ body: dict[str, Any],
405
+ headers: dict[str, str],
406
+ ) -> AsyncGenerator[LLMEvent, None]:
407
+ body_bytes = json.dumps(body).encode()
408
+ last_error: Exception | None = None
409
+
410
+ # Per-call client in an async-with so the connection pool is always
411
+ # closed when the stream ends or is torn down — no persistent client
412
+ # left unclosed for the GC to warn about.
413
+ async with httpx.AsyncClient(
414
+ timeout=self.options.timeout.total_seconds(),
415
+ headers=self.options.headers or {},
416
+ ) as client:
417
+ for attempt in range(_MAX_RETRIES + 1):
418
+ if attempt > 0:
419
+ await asyncio.sleep(_BASE_DELAY_S * (2 ** (attempt - 1)))
420
+ try:
421
+ async with client.stream(
422
+ "POST", self._http_url, content=body_bytes, headers=headers,
423
+ ) as response:
424
+ if self.options.on_response:
425
+ self.options.on_response(APIResponse(response.status_code, dict(response.headers)))
426
+
427
+ if not response.is_success:
428
+ text = (await response.aread()).decode(errors="replace")
429
+ if attempt < _MAX_RETRIES and _is_retryable(response.status_code, text):
430
+ last_error = RuntimeError(f"HTTP {response.status_code}: {text}")
431
+ continue
432
+ raise RuntimeError(f"HTTP {response.status_code}: {text}")
433
+
434
+ async for event in _process_events(_map_codex_events(_parse_sse(response))):
435
+ yield event
436
+ return
437
+
438
+ except RuntimeError:
439
+ raise
440
+ except Exception as exc:
441
+ last_error = exc
442
+ if attempt < _MAX_RETRIES:
443
+ continue
444
+ raise
445
+
446
+ raise last_error or RuntimeError("Failed after retries")
447
+
448
+ async def _stream_ws(
449
+ self,
450
+ body: dict[str, Any],
451
+ headers: dict[str, str],
452
+ ) -> AsyncGenerator[LLMEvent, None]:
453
+ ws_headers = {k: v for k, v in headers.items() if k.lower() not in ("accept", "content-type")}
454
+ async with websockets.asyncio.client.connect(
455
+ self._ws_url,
456
+ additional_headers=ws_headers,
457
+ ) as ws:
458
+ await ws.send(json.dumps({"type": "response.create", **body}))
459
+ async for event in _process_events(_map_codex_events(_parse_ws(ws))):
460
+ yield event
461
+
462
+ async def stream(self, context: LLMContext, model: Model) -> AsyncGenerator[LLMEvent, None]: # type: ignore[override]
463
+ token = self.options.api_key or ""
464
+ account_id = _extract_account_id(token)
465
+ instructions, input_items = _messages_to_input(context.messages)
466
+ body = _build_body(model, instructions, input_items, self.options, tools=context.tools or None)
467
+ text_format = _text_format(context.response_format)
468
+ if text_format is not None:
469
+ body["text"] = {**body.get("text", {}), **text_format}
470
+
471
+ if self.options.on_payload:
472
+ modified = self.options.on_payload(body)
473
+ if modified is not None:
474
+ body = modified
475
+
476
+ yield StartEvent()
477
+
478
+ if self.options.transport == Transport.WEBSOCKET:
479
+ headers = _build_headers(token, account_id, websocket=True)
480
+ stream_iter = self._stream_ws(body, headers)
481
+ else:
482
+ headers = _build_headers(token, account_id, websocket=False)
483
+ stream_iter = self._stream_sse(body, headers)
484
+
485
+ cancelled = False
486
+ # aclosing() so breaking out on cancellation deterministically tears
487
+ # down the inner SSE/WS stream (and its httpx/websocket connection)
488
+ # here, instead of leaving it to the GC asyncgen finalizer
489
+ # ("Task was destroyed but it is pending!").
490
+ async with aclosing(stream_iter) as stream:
491
+ async for event in stream:
492
+ if self._cancelled():
493
+ cancelled = True
494
+ break
495
+ yield event
496
+ if cancelled:
497
+ yield ErrorEvent(reason=StopReason.Abort, error="Cancelled")