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.
- tau/__init__.py +0 -0
- tau/agent/__init__.py +11 -0
- tau/agent/prompt/__init__.py +10 -0
- tau/agent/prompt/builder.py +302 -0
- tau/agent/prompt/types.py +33 -0
- tau/agent/service.py +369 -0
- tau/agent/types.py +61 -0
- tau/auth/manager.py +247 -0
- tau/auth/storage.py +82 -0
- tau/auth/types.py +41 -0
- tau/builtins/__init__.py +4 -0
- tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__init__.py +41 -0
- tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
- tau/builtins/commands/clear.py +16 -0
- tau/builtins/commands/compact.py +28 -0
- tau/builtins/commands/reload.py +27 -0
- tau/builtins/commands/session.py +19 -0
- tau/builtins/extensions/footer/__init__.py +76 -0
- tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/git.py +26 -0
- tau/builtins/extensions/footer/model.py +69 -0
- tau/builtins/extensions/footer/utils.py +44 -0
- tau/builtins/extensions/header/__init__.py +18 -0
- tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__init__.py +0 -0
- tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/models/audio.py +43 -0
- tau/builtins/models/image.py +43 -0
- tau/builtins/models/text.py +482 -0
- tau/builtins/models/video.py +40 -0
- tau/builtins/prompts/commit.md +7 -0
- tau/builtins/prompts/docs.md +7 -0
- tau/builtins/prompts/explain.md +7 -0
- tau/builtins/prompts/fix.md +7 -0
- tau/builtins/prompts/refactor.md +7 -0
- tau/builtins/prompts/review.md +7 -0
- tau/builtins/prompts/test.md +7 -0
- tau/builtins/providers/__init__.py +0 -0
- tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/providers/audio.py +10 -0
- tau/builtins/providers/image.py +9 -0
- tau/builtins/providers/text.py +33 -0
- tau/builtins/providers/video.py +6 -0
- tau/builtins/skills/code-review/SKILL.md +4 -0
- tau/builtins/skills/debug/SKILL.md +4 -0
- tau/builtins/skills/git-commit/SKILL.md +4 -0
- tau/builtins/themes/dark.yaml +1 -0
- tau/builtins/themes/light.yaml +46 -0
- tau/builtins/tools/__init__.py +73 -0
- tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
- tau/builtins/tools/edit.py +215 -0
- tau/builtins/tools/glob.py +112 -0
- tau/builtins/tools/grep.py +146 -0
- tau/builtins/tools/ls.py +135 -0
- tau/builtins/tools/read.py +122 -0
- tau/builtins/tools/terminal.py +150 -0
- tau/builtins/tools/write.py +105 -0
- tau/commands/__init__.py +10 -0
- tau/commands/registry.py +71 -0
- tau/commands/types.py +33 -0
- tau/console/__init__.py +0 -0
- tau/console/cli.py +266 -0
- tau/console/commands/__init__.py +0 -0
- tau/console/commands/auth.py +193 -0
- tau/console/commands/packages.py +104 -0
- tau/console/commands/update.py +76 -0
- tau/core/__init__.py +0 -0
- tau/core/registry.py +102 -0
- tau/engine/__init__.py +47 -0
- tau/engine/service.py +768 -0
- tau/engine/types.py +163 -0
- tau/extensions/__init__.py +28 -0
- tau/extensions/api.py +928 -0
- tau/extensions/context.py +462 -0
- tau/extensions/events.py +70 -0
- tau/extensions/loader.py +386 -0
- tau/extensions/runtime.py +184 -0
- tau/extensions/settings.py +137 -0
- tau/hooks/__init__.py +112 -0
- tau/hooks/engine.py +237 -0
- tau/hooks/inference.py +21 -0
- tau/hooks/runtime.py +126 -0
- tau/hooks/service.py +121 -0
- tau/hooks/session.py +117 -0
- tau/hooks/tui.py +61 -0
- tau/hooks/types.py +72 -0
- tau/inference/__init__.py +180 -0
- tau/inference/api/__init__.py +0 -0
- tau/inference/api/audio/__init__.py +0 -0
- tau/inference/api/audio/base.py +29 -0
- tau/inference/api/audio/builtins.py +15 -0
- tau/inference/api/audio/elevenlabs_audio.py +183 -0
- tau/inference/api/audio/gemini_audio.py +95 -0
- tau/inference/api/audio/openai_audio.py +159 -0
- tau/inference/api/audio/registry.py +15 -0
- tau/inference/api/audio/sarvam_audio.py +163 -0
- tau/inference/api/audio/service.py +103 -0
- tau/inference/api/audio/utils.py +47 -0
- tau/inference/api/image/__init__.py +0 -0
- tau/inference/api/image/base.py +17 -0
- tau/inference/api/image/builtins.py +8 -0
- tau/inference/api/image/gemini_image.py +77 -0
- tau/inference/api/image/openai_image.py +103 -0
- tau/inference/api/image/openrouter.py +144 -0
- tau/inference/api/image/registry.py +15 -0
- tau/inference/api/image/service.py +71 -0
- tau/inference/api/registry.py +82 -0
- tau/inference/api/text/__init__.py +0 -0
- tau/inference/api/text/anthropic_claude_code.py +222 -0
- tau/inference/api/text/anthropic_messages.py +196 -0
- tau/inference/api/text/base.py +40 -0
- tau/inference/api/text/builtins.py +19 -0
- tau/inference/api/text/gemini_generate.py +234 -0
- tau/inference/api/text/github_copilot_chat.py +172 -0
- tau/inference/api/text/google_antigravity.py +522 -0
- tau/inference/api/text/mistral_chat.py +284 -0
- tau/inference/api/text/ollama_chat.py +200 -0
- tau/inference/api/text/openai_codex_responses.py +497 -0
- tau/inference/api/text/openai_completions.py +227 -0
- tau/inference/api/text/openai_responses.py +235 -0
- tau/inference/api/text/registry.py +50 -0
- tau/inference/api/text/service.py +297 -0
- tau/inference/api/text/types.py +7 -0
- tau/inference/api/text/utils.py +228 -0
- tau/inference/api/video/__init__.py +0 -0
- tau/inference/api/video/base.py +26 -0
- tau/inference/api/video/builtins.py +7 -0
- tau/inference/api/video/fal_video.py +119 -0
- tau/inference/api/video/openrouter_video.py +142 -0
- tau/inference/api/video/registry.py +15 -0
- tau/inference/api/video/service.py +72 -0
- tau/inference/model/__init__.py +0 -0
- tau/inference/model/registry.py +102 -0
- tau/inference/model/types.py +65 -0
- tau/inference/provider/__init__.py +0 -0
- tau/inference/provider/oauth/__init__.py +35 -0
- tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
- tau/inference/provider/oauth/github_copilot.py +333 -0
- tau/inference/provider/oauth/google_antigravity.py +258 -0
- tau/inference/provider/oauth/openai_codex.py +309 -0
- tau/inference/provider/oauth/pkce.py +14 -0
- tau/inference/provider/oauth/types.py +46 -0
- tau/inference/provider/oauth/utils.py +154 -0
- tau/inference/provider/registry.py +141 -0
- tau/inference/provider/types.py +114 -0
- tau/inference/types.py +549 -0
- tau/inference/utils.py +219 -0
- tau/message/__init__.py +0 -0
- tau/message/types.py +482 -0
- tau/message/utils.py +178 -0
- tau/packages/__init__.py +11 -0
- tau/packages/manager.py +190 -0
- tau/packages/types.py +20 -0
- tau/packages/utils.py +67 -0
- tau/prompts/expand.py +58 -0
- tau/prompts/loader.py +69 -0
- tau/prompts/registry.py +45 -0
- tau/prompts/types.py +24 -0
- tau/rpc/__init__.py +8 -0
- tau/rpc/mode.py +783 -0
- tau/rpc/types.py +252 -0
- tau/runtime/service.py +759 -0
- tau/runtime/types.py +303 -0
- tau/session/branch_summarization.py +312 -0
- tau/session/compaction.py +646 -0
- tau/session/manager.py +652 -0
- tau/session/types.py +188 -0
- tau/session/utils.py +233 -0
- tau/settings/manager.py +1077 -0
- tau/settings/paths.py +150 -0
- tau/settings/storage.py +63 -0
- tau/settings/types.py +173 -0
- tau/settings/utils.py +25 -0
- tau/skills/loader.py +91 -0
- tau/skills/registry.py +70 -0
- tau/skills/types.py +25 -0
- tau/themes/loader.py +238 -0
- tau/themes/registry.py +108 -0
- tau/themes/types.py +19 -0
- tau/tool/__init__.py +3 -0
- tau/tool/registry.py +117 -0
- tau/tool/render.py +21 -0
- tau/tool/types.py +244 -0
- tau/trust/__init__.py +13 -0
- tau/trust/manager.py +80 -0
- tau/trust/types.py +14 -0
- tau/trust/utils.py +72 -0
- tau/tui/__init__.py +54 -0
- tau/tui/agent_hooks.py +346 -0
- tau/tui/ansi.py +330 -0
- tau/tui/app.py +540 -0
- tau/tui/autocomplete.py +33 -0
- tau/tui/capabilities.py +119 -0
- tau/tui/commands/__init__.py +3 -0
- tau/tui/commands/appearance.py +498 -0
- tau/tui/commands/auth.py +232 -0
- tau/tui/commands/context.py +38 -0
- tau/tui/commands/misc.py +82 -0
- tau/tui/commands/model.py +118 -0
- tau/tui/commands/session.py +464 -0
- tau/tui/component.py +268 -0
- tau/tui/components/__init__.py +0 -0
- tau/tui/components/autocomplete_manager.py +267 -0
- tau/tui/components/autocomplete_picker.py +143 -0
- tau/tui/components/box.py +90 -0
- tau/tui/components/command_palette.py +144 -0
- tau/tui/components/dynamic_border.py +19 -0
- tau/tui/components/file_picker.py +233 -0
- tau/tui/components/image.py +181 -0
- tau/tui/components/inline_selector.py +71 -0
- tau/tui/components/layout.py +1194 -0
- tau/tui/components/message_list.py +692 -0
- tau/tui/components/modal.py +97 -0
- tau/tui/components/model_palette.py +204 -0
- tau/tui/components/picker_overlay.py +174 -0
- tau/tui/components/prompt_overlay.py +236 -0
- tau/tui/components/resume_modal.py +372 -0
- tau/tui/components/select_list.py +222 -0
- tau/tui/components/settings_modal.py +274 -0
- tau/tui/components/settings_schema.py +203 -0
- tau/tui/components/spinner.py +119 -0
- tau/tui/components/text_input.py +396 -0
- tau/tui/components/text_prompt.py +82 -0
- tau/tui/components/tree_select_list.py +580 -0
- tau/tui/components/trust_screen.py +97 -0
- tau/tui/diff.py +114 -0
- tau/tui/fuzzy.py +99 -0
- tau/tui/input.py +496 -0
- tau/tui/input_handler.py +716 -0
- tau/tui/keybindings.py +87 -0
- tau/tui/markdown.py +286 -0
- tau/tui/message_renderers.py +31 -0
- tau/tui/overlay.py +326 -0
- tau/tui/renderer.py +378 -0
- tau/tui/terminal.py +499 -0
- tau/tui/theme.py +148 -0
- tau/tui/tui.py +544 -0
- tau/tui/ui_context.py +768 -0
- tau/tui/utils.py +20 -0
- tau/utils/__init__.py +0 -0
- tau/utils/http_proxy.py +221 -0
- tau/utils/image_processing.py +172 -0
- tau/utils/secrets.py +59 -0
- tau/utils/version_check.py +60 -0
- tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
- tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
- tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
- tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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")
|