windcode 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.
- windcode/__init__.py +6 -0
- windcode/__main__.py +4 -0
- windcode/auth/__init__.py +11 -0
- windcode/auth/store.py +90 -0
- windcode/cli.py +181 -0
- windcode/config/__init__.py +41 -0
- windcode/config/loader.py +117 -0
- windcode/config/models.py +203 -0
- windcode/config/writer.py +74 -0
- windcode/context/__init__.py +18 -0
- windcode/context/compactor.py +72 -0
- windcode/context/estimator.py +89 -0
- windcode/context/truncation.py +87 -0
- windcode/domain/__init__.py +1 -0
- windcode/domain/errors.py +33 -0
- windcode/domain/events.py +597 -0
- windcode/domain/messages.py +226 -0
- windcode/domain/models.py +73 -0
- windcode/domain/subagents.py +263 -0
- windcode/domain/tools.py +55 -0
- windcode/extensions/__init__.py +27 -0
- windcode/extensions/commands.py +25 -0
- windcode/extensions/discovery.py +139 -0
- windcode/extensions/events.py +58 -0
- windcode/extensions/hooks/__init__.py +4 -0
- windcode/extensions/hooks/dispatcher.py +107 -0
- windcode/extensions/hooks/executor.py +49 -0
- windcode/extensions/hooks/loader.py +101 -0
- windcode/extensions/hooks/models.py +109 -0
- windcode/extensions/mcp/__init__.py +10 -0
- windcode/extensions/mcp/adapter.py +101 -0
- windcode/extensions/mcp/catalog.py +153 -0
- windcode/extensions/mcp/client.py +273 -0
- windcode/extensions/mcp/runtime.py +144 -0
- windcode/extensions/mcp/tools.py +570 -0
- windcode/extensions/models.py +168 -0
- windcode/extensions/paths.py +71 -0
- windcode/extensions/plugins/__init__.py +3 -0
- windcode/extensions/plugins/installer.py +93 -0
- windcode/extensions/plugins/manifest.py +166 -0
- windcode/extensions/runtime.py +366 -0
- windcode/extensions/service.py +437 -0
- windcode/extensions/skills/__init__.py +3 -0
- windcode/extensions/skills/loader.py +67 -0
- windcode/extensions/skills/parser.py +57 -0
- windcode/extensions/skills/tools.py +213 -0
- windcode/extensions/snapshot.py +57 -0
- windcode/extensions/state.py +158 -0
- windcode/instructions/__init__.py +8 -0
- windcode/instructions/loader.py +50 -0
- windcode/memory/__init__.py +57 -0
- windcode/memory/extraction.py +83 -0
- windcode/memory/models.py +218 -0
- windcode/memory/refiner.py +189 -0
- windcode/memory/security.py +23 -0
- windcode/memory/service.py +179 -0
- windcode/memory/store.py +362 -0
- windcode/observability/__init__.py +4 -0
- windcode/observability/redaction.py +95 -0
- windcode/observability/trace.py +115 -0
- windcode/policy/__init__.py +22 -0
- windcode/policy/engine.py +137 -0
- windcode/policy/models.py +69 -0
- windcode/providers/__init__.py +29 -0
- windcode/providers/_utils.py +19 -0
- windcode/providers/anthropic.py +215 -0
- windcode/providers/base.py +46 -0
- windcode/providers/catalog.py +119 -0
- windcode/providers/errors.py +96 -0
- windcode/providers/openai_compat.py +231 -0
- windcode/providers/openai_responses.py +189 -0
- windcode/providers/registry.py +105 -0
- windcode/runtime/__init__.py +15 -0
- windcode/runtime/control.py +89 -0
- windcode/runtime/event_bus.py +67 -0
- windcode/runtime/loop.py +510 -0
- windcode/runtime/prompts.py +132 -0
- windcode/runtime/report.py +64 -0
- windcode/runtime/retry.py +73 -0
- windcode/runtime/scheduler.py +194 -0
- windcode/runtime/subagents/__init__.py +28 -0
- windcode/runtime/subagents/approvals.py +88 -0
- windcode/runtime/subagents/budgets.py +79 -0
- windcode/runtime/subagents/coordinator.py +714 -0
- windcode/runtime/subagents/factory.py +311 -0
- windcode/runtime/subagents/roles.py +74 -0
- windcode/runtime/subagents/verification.py +53 -0
- windcode/sandbox/__init__.py +3 -0
- windcode/sandbox/bwrap.py +79 -0
- windcode/sdk.py +1259 -0
- windcode/sessions/__init__.py +23 -0
- windcode/sessions/artifacts.py +69 -0
- windcode/sessions/models.py +104 -0
- windcode/sessions/store.py +167 -0
- windcode/sessions/tree.py +35 -0
- windcode/tools/__init__.py +10 -0
- windcode/tools/apply_patch.py +191 -0
- windcode/tools/ask_user.py +58 -0
- windcode/tools/builtins.py +44 -0
- windcode/tools/edit_file.py +54 -0
- windcode/tools/filesystem.py +77 -0
- windcode/tools/glob.py +35 -0
- windcode/tools/grep.py +66 -0
- windcode/tools/memory.py +400 -0
- windcode/tools/read_file.py +54 -0
- windcode/tools/registry.py +120 -0
- windcode/tools/shell.py +159 -0
- windcode/tools/subagents/__init__.py +31 -0
- windcode/tools/subagents/cancel.py +35 -0
- windcode/tools/subagents/integrate.py +54 -0
- windcode/tools/subagents/list.py +46 -0
- windcode/tools/subagents/spawn.py +86 -0
- windcode/tools/subagents/wait.py +74 -0
- windcode/tools/write_file.py +61 -0
- windcode/tui/__init__.py +3 -0
- windcode/tui/app.py +1015 -0
- windcode/tui/commands.py +90 -0
- windcode/tui/permission_display.py +24 -0
- windcode/tui/styles.tcss +591 -0
- windcode/tui/widgets/__init__.py +31 -0
- windcode/tui/widgets/approval.py +98 -0
- windcode/tui/widgets/command_menu.py +90 -0
- windcode/tui/widgets/extensions.py +48 -0
- windcode/tui/widgets/input.py +110 -0
- windcode/tui/widgets/memory.py +143 -0
- windcode/tui/widgets/messages.py +299 -0
- windcode/tui/widgets/models.py +565 -0
- windcode/tui/widgets/question.py +46 -0
- windcode/tui/widgets/sessions.py +68 -0
- windcode/tui/widgets/status.py +46 -0
- windcode/tui/widgets/subagents.py +195 -0
- windcode/tui/widgets/tools.py +66 -0
- windcode/tui/widgets/welcome.py +149 -0
- windcode/types.py +80 -0
- windcode/worktrees/__init__.py +24 -0
- windcode/worktrees/git.py +92 -0
- windcode/worktrees/manager.py +265 -0
- windcode/worktrees/models.py +62 -0
- windcode-0.1.0.dist-info/METADATA +207 -0
- windcode-0.1.0.dist-info/RECORD +143 -0
- windcode-0.1.0.dist-info/WHEEL +4 -0
- windcode-0.1.0.dist-info/entry_points.txt +2 -0
- windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from windcode.domain.errors import ErrorCategory, WindcodeError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProviderError(WindcodeError):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
message: str,
|
|
15
|
+
category: ErrorCategory,
|
|
16
|
+
*,
|
|
17
|
+
status_code: int | None = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
super().__init__(message, category)
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _status_code(error: BaseException) -> int | None:
|
|
24
|
+
value = getattr(error, "status_code", None)
|
|
25
|
+
if isinstance(value, int):
|
|
26
|
+
return value
|
|
27
|
+
response = getattr(error, "response", None)
|
|
28
|
+
value = getattr(response, "status", None)
|
|
29
|
+
return value if isinstance(value, int) else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _category_from_status(status: int, message: str) -> ErrorCategory:
|
|
33
|
+
if status in {401, 403}:
|
|
34
|
+
return ErrorCategory.AUTHENTICATION
|
|
35
|
+
if status == 429:
|
|
36
|
+
return ErrorCategory.RATE_LIMIT
|
|
37
|
+
if status in {408, 409, 425}:
|
|
38
|
+
return ErrorCategory.NETWORK
|
|
39
|
+
if status >= 500:
|
|
40
|
+
return ErrorCategory.SERVER
|
|
41
|
+
lowered = message.casefold()
|
|
42
|
+
if status == 400 and any(
|
|
43
|
+
phrase in lowered for phrase in ("context length", "context window", "too many tokens")
|
|
44
|
+
):
|
|
45
|
+
return ErrorCategory.CONTEXT_OVERFLOW
|
|
46
|
+
if status in {400, 404, 405, 422}:
|
|
47
|
+
return ErrorCategory.INVALID_REQUEST
|
|
48
|
+
return ErrorCategory.INTERNAL
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def map_provider_error(error: BaseException) -> WindcodeError:
|
|
52
|
+
if isinstance(error, WindcodeError):
|
|
53
|
+
return error
|
|
54
|
+
if isinstance(error, asyncio.CancelledError):
|
|
55
|
+
return ProviderError("model request cancelled", ErrorCategory.CANCELLED)
|
|
56
|
+
if isinstance(error, (TimeoutError, aiohttp.ClientConnectionError)):
|
|
57
|
+
return ProviderError(str(error) or "model network request failed", ErrorCategory.NETWORK)
|
|
58
|
+
if isinstance(error, aiohttp.ClientError):
|
|
59
|
+
status = _status_code(error)
|
|
60
|
+
category = (
|
|
61
|
+
_category_from_status(status, str(error))
|
|
62
|
+
if status is not None
|
|
63
|
+
else ErrorCategory.NETWORK
|
|
64
|
+
)
|
|
65
|
+
return ProviderError(str(error), category, status_code=status)
|
|
66
|
+
|
|
67
|
+
status = _status_code(error)
|
|
68
|
+
if status is not None:
|
|
69
|
+
return ProviderError(
|
|
70
|
+
str(error),
|
|
71
|
+
_category_from_status(status, str(error)),
|
|
72
|
+
status_code=status,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
class_name = type(error).__name__.casefold()
|
|
76
|
+
if "authentication" in class_name or "permission" in class_name:
|
|
77
|
+
category = ErrorCategory.AUTHENTICATION
|
|
78
|
+
elif "ratelimit" in class_name or "rate_limit" in class_name:
|
|
79
|
+
category = ErrorCategory.RATE_LIMIT
|
|
80
|
+
elif "connection" in class_name or "timeout" in class_name:
|
|
81
|
+
category = ErrorCategory.NETWORK
|
|
82
|
+
elif "content" in class_name and ("filter" in class_name or "policy" in class_name):
|
|
83
|
+
category = ErrorCategory.CONTENT_POLICY
|
|
84
|
+
elif "badrequest" in class_name or "invalidrequest" in class_name:
|
|
85
|
+
category = _category_from_status(400, str(error))
|
|
86
|
+
else:
|
|
87
|
+
category = ErrorCategory.INTERNAL
|
|
88
|
+
return ProviderError(str(error), category, status_code=cast(int | None, status))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def error_from_http_status(status_code: int, message: str) -> ProviderError:
|
|
92
|
+
return ProviderError(
|
|
93
|
+
message,
|
|
94
|
+
_category_from_status(status_code, message),
|
|
95
|
+
status_code=status_code,
|
|
96
|
+
)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
|
|
9
|
+
from windcode.domain.errors import ErrorCategory
|
|
10
|
+
from windcode.domain.messages import (
|
|
11
|
+
AttachmentBlock,
|
|
12
|
+
Message,
|
|
13
|
+
ReasoningBlock,
|
|
14
|
+
TextBlock,
|
|
15
|
+
ToolCallBlock,
|
|
16
|
+
ToolResultBlock,
|
|
17
|
+
)
|
|
18
|
+
from windcode.domain.models import (
|
|
19
|
+
ModelCompleted,
|
|
20
|
+
ModelEvent,
|
|
21
|
+
ModelRequest,
|
|
22
|
+
ModelUsage,
|
|
23
|
+
ReasoningDelta,
|
|
24
|
+
StopReason,
|
|
25
|
+
TextDelta,
|
|
26
|
+
ToolCallDelta,
|
|
27
|
+
Usage,
|
|
28
|
+
)
|
|
29
|
+
from windcode.providers._utils import as_int, as_string, get_value
|
|
30
|
+
from windcode.providers.base import BaseTransport
|
|
31
|
+
from windcode.providers.errors import ProviderError, error_from_http_status, map_provider_error
|
|
32
|
+
|
|
33
|
+
ChunkFactory = Callable[[ModelRequest], AsyncIterator[Mapping[str, object]]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_chat_messages(
|
|
37
|
+
messages: tuple[Message, ...], system_prompt: str
|
|
38
|
+
) -> list[dict[str, object]]:
|
|
39
|
+
converted: list[dict[str, object]] = []
|
|
40
|
+
if system_prompt:
|
|
41
|
+
converted.append({"role": "system", "content": system_prompt})
|
|
42
|
+
for message in messages:
|
|
43
|
+
text_parts: list[str] = []
|
|
44
|
+
tool_calls: list[dict[str, object]] = []
|
|
45
|
+
tool_results: list[ToolResultBlock] = []
|
|
46
|
+
for block in message.content:
|
|
47
|
+
if isinstance(block, TextBlock):
|
|
48
|
+
text_parts.append(block.text)
|
|
49
|
+
elif isinstance(block, ReasoningBlock):
|
|
50
|
+
text_parts.append(block.summary)
|
|
51
|
+
elif isinstance(block, AttachmentBlock):
|
|
52
|
+
text_parts.append(f"[attachment: {block.description or block.reference}]")
|
|
53
|
+
elif isinstance(block, ToolCallBlock):
|
|
54
|
+
tool_calls.append(
|
|
55
|
+
{
|
|
56
|
+
"id": block.call_id,
|
|
57
|
+
"type": "function",
|
|
58
|
+
"function": {
|
|
59
|
+
"name": block.name,
|
|
60
|
+
"arguments": json.dumps(block.arguments, separators=(",", ":")),
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
tool_results.append(block)
|
|
66
|
+
if text_parts or tool_calls:
|
|
67
|
+
item: dict[str, object] = {
|
|
68
|
+
"role": message.role.value,
|
|
69
|
+
"content": "\n".join(text_parts) or None,
|
|
70
|
+
}
|
|
71
|
+
if tool_calls:
|
|
72
|
+
item["tool_calls"] = tool_calls
|
|
73
|
+
converted.append(item)
|
|
74
|
+
for result in tool_results:
|
|
75
|
+
converted.append(
|
|
76
|
+
{"role": "tool", "tool_call_id": result.call_id, "content": result.content}
|
|
77
|
+
)
|
|
78
|
+
return converted
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_chat_tools(request: ModelRequest) -> list[dict[str, object]]:
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
"type": "function",
|
|
85
|
+
"function": {
|
|
86
|
+
"name": tool.name,
|
|
87
|
+
"description": tool.description,
|
|
88
|
+
"parameters": tool.parameters,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
for tool in request.tools
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _mapping(value: object) -> Mapping[str, object]:
|
|
96
|
+
if not isinstance(value, Mapping):
|
|
97
|
+
return {}
|
|
98
|
+
raw = cast(Mapping[object, object], value)
|
|
99
|
+
return {str(key): child for key, child in raw.items()}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class OpenAICompatibleTransport(BaseTransport):
|
|
103
|
+
name = "openai_compatible"
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
api_key: str,
|
|
109
|
+
base_url: str,
|
|
110
|
+
session: aiohttp.ClientSession | None = None,
|
|
111
|
+
chunk_factory: ChunkFactory | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
super().__init__()
|
|
114
|
+
self.api_key = api_key
|
|
115
|
+
self.base_url = base_url.rstrip("/")
|
|
116
|
+
self._session = session
|
|
117
|
+
self._owns_session = session is None
|
|
118
|
+
self._chunk_factory = chunk_factory or self._http_chunks
|
|
119
|
+
if session is not None:
|
|
120
|
+
self._owns_session = False
|
|
121
|
+
|
|
122
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
123
|
+
if self._session is None:
|
|
124
|
+
self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=None))
|
|
125
|
+
self.add_close_callback(self._session.close)
|
|
126
|
+
return self._session
|
|
127
|
+
|
|
128
|
+
async def _http_chunks(self, request: ModelRequest) -> AsyncIterator[Mapping[str, object]]:
|
|
129
|
+
session = await self._get_session()
|
|
130
|
+
url = (
|
|
131
|
+
self.base_url
|
|
132
|
+
if self.base_url.endswith("/chat/completions")
|
|
133
|
+
else f"{self.base_url}/chat/completions"
|
|
134
|
+
)
|
|
135
|
+
body: dict[str, object] = {
|
|
136
|
+
"model": request.model,
|
|
137
|
+
"messages": build_chat_messages(request.messages, request.system_prompt),
|
|
138
|
+
"tools": build_chat_tools(request),
|
|
139
|
+
"stream": True,
|
|
140
|
+
"stream_options": {"include_usage": True},
|
|
141
|
+
}
|
|
142
|
+
if request.max_output_tokens is not None:
|
|
143
|
+
body["max_tokens"] = request.max_output_tokens
|
|
144
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Accept": "text/event-stream"}
|
|
145
|
+
async with session.post(url, json=body, headers=headers) as response:
|
|
146
|
+
if response.status >= 400:
|
|
147
|
+
message = await response.text()
|
|
148
|
+
raise error_from_http_status(response.status, message)
|
|
149
|
+
async for raw_line in response.content:
|
|
150
|
+
line = raw_line.decode("utf-8", errors="strict").strip()
|
|
151
|
+
if not line or line.startswith(":") or not line.startswith("data:"):
|
|
152
|
+
continue
|
|
153
|
+
data = line.removeprefix("data:").strip()
|
|
154
|
+
if data == "[DONE]":
|
|
155
|
+
return
|
|
156
|
+
try:
|
|
157
|
+
decoded = json.loads(data)
|
|
158
|
+
except json.JSONDecodeError as exc:
|
|
159
|
+
raise ProviderError(f"invalid SSE JSON: {exc}", ErrorCategory.NETWORK) from exc
|
|
160
|
+
if not isinstance(decoded, Mapping):
|
|
161
|
+
raise ProviderError("SSE payload must be an object", ErrorCategory.NETWORK)
|
|
162
|
+
raw = cast(Mapping[object, object], decoded)
|
|
163
|
+
yield {str(key): value for key, value in raw.items()}
|
|
164
|
+
|
|
165
|
+
async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
|
|
166
|
+
self.ensure_open()
|
|
167
|
+
usage = Usage()
|
|
168
|
+
calls: dict[int, tuple[str, str]] = {}
|
|
169
|
+
completed = False
|
|
170
|
+
try:
|
|
171
|
+
async for chunk in self._chunk_factory(request):
|
|
172
|
+
error = get_value(chunk, "error")
|
|
173
|
+
if error is not None:
|
|
174
|
+
message = as_string(get_value(error, "message"), "provider returned an error")
|
|
175
|
+
raise ProviderError(message, ErrorCategory.INVALID_REQUEST)
|
|
176
|
+
raw_usage = get_value(chunk, "usage")
|
|
177
|
+
if raw_usage is not None:
|
|
178
|
+
prompt_details = get_value(raw_usage, "prompt_tokens_details")
|
|
179
|
+
usage = Usage(
|
|
180
|
+
input_tokens=as_int(get_value(raw_usage, "prompt_tokens")),
|
|
181
|
+
output_tokens=as_int(get_value(raw_usage, "completion_tokens")),
|
|
182
|
+
cache_read_tokens=as_int(get_value(prompt_details, "cached_tokens")),
|
|
183
|
+
)
|
|
184
|
+
yield ModelUsage(usage)
|
|
185
|
+
raw_choices = get_value(chunk, "choices", [])
|
|
186
|
+
if not isinstance(raw_choices, list) or not raw_choices:
|
|
187
|
+
continue
|
|
188
|
+
choices = cast(list[object], raw_choices)
|
|
189
|
+
choice = choices[0]
|
|
190
|
+
delta = get_value(choice, "delta", {})
|
|
191
|
+
text = as_string(get_value(delta, "content"))
|
|
192
|
+
if text:
|
|
193
|
+
yield TextDelta(text)
|
|
194
|
+
reasoning = as_string(
|
|
195
|
+
get_value(delta, "reasoning_content", get_value(delta, "reasoning", ""))
|
|
196
|
+
)
|
|
197
|
+
if reasoning:
|
|
198
|
+
yield ReasoningDelta(reasoning)
|
|
199
|
+
raw_tool_calls_value = get_value(delta, "tool_calls", [])
|
|
200
|
+
if isinstance(raw_tool_calls_value, list):
|
|
201
|
+
raw_tool_calls = cast(list[object], raw_tool_calls_value)
|
|
202
|
+
for raw_call in raw_tool_calls:
|
|
203
|
+
call = _mapping(raw_call)
|
|
204
|
+
index = as_int(get_value(call, "index"))
|
|
205
|
+
function = get_value(call, "function", {})
|
|
206
|
+
previous_id, previous_name = calls.get(index, ("", ""))
|
|
207
|
+
call_id = as_string(get_value(call, "id"), previous_id)
|
|
208
|
+
name = as_string(get_value(function, "name"), previous_name)
|
|
209
|
+
calls[index] = (call_id, name)
|
|
210
|
+
yield ToolCallDelta(
|
|
211
|
+
call_id,
|
|
212
|
+
name,
|
|
213
|
+
as_string(get_value(function, "arguments")),
|
|
214
|
+
)
|
|
215
|
+
finish_reason = as_string(get_value(choice, "finish_reason"))
|
|
216
|
+
if finish_reason:
|
|
217
|
+
reason = {
|
|
218
|
+
"tool_calls": StopReason.TOOL_USE,
|
|
219
|
+
"length": StopReason.MAX_TOKENS,
|
|
220
|
+
"content_filter": StopReason.CONTENT_FILTER,
|
|
221
|
+
"stop": StopReason.STOP,
|
|
222
|
+
}.get(finish_reason, StopReason.STOP)
|
|
223
|
+
yield ModelCompleted(reason, usage)
|
|
224
|
+
completed = True
|
|
225
|
+
if not completed:
|
|
226
|
+
raise ProviderError(
|
|
227
|
+
"chat completion stream ended without a finish reason",
|
|
228
|
+
ErrorCategory.NETWORK,
|
|
229
|
+
)
|
|
230
|
+
except BaseException as exc:
|
|
231
|
+
raise map_provider_error(exc) from exc
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncIterator, Callable
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from openai import AsyncOpenAI, DefaultAioHttpClient
|
|
8
|
+
from openai.types.responses import ResponseInputParam, ToolParam
|
|
9
|
+
|
|
10
|
+
from windcode.domain.messages import (
|
|
11
|
+
AttachmentBlock,
|
|
12
|
+
Message,
|
|
13
|
+
ReasoningBlock,
|
|
14
|
+
TextBlock,
|
|
15
|
+
ToolCallBlock,
|
|
16
|
+
ToolResultBlock,
|
|
17
|
+
)
|
|
18
|
+
from windcode.domain.models import (
|
|
19
|
+
ModelCompleted,
|
|
20
|
+
ModelEvent,
|
|
21
|
+
ModelRequest,
|
|
22
|
+
ModelUsage,
|
|
23
|
+
ReasoningDelta,
|
|
24
|
+
StopReason,
|
|
25
|
+
TextDelta,
|
|
26
|
+
ToolCallDelta,
|
|
27
|
+
Usage,
|
|
28
|
+
)
|
|
29
|
+
from windcode.providers._utils import as_int, as_string, get_value
|
|
30
|
+
from windcode.providers.base import BaseTransport
|
|
31
|
+
from windcode.providers.errors import map_provider_error
|
|
32
|
+
|
|
33
|
+
RawStreamFactory = Callable[[ModelRequest], AsyncIterator[object]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _message_text(message: Message) -> str:
|
|
37
|
+
parts: list[str] = []
|
|
38
|
+
for block in message.content:
|
|
39
|
+
if isinstance(block, TextBlock):
|
|
40
|
+
parts.append(block.text)
|
|
41
|
+
elif isinstance(block, ReasoningBlock):
|
|
42
|
+
parts.append(block.summary)
|
|
43
|
+
elif isinstance(block, AttachmentBlock):
|
|
44
|
+
parts.append(f"[attachment: {block.description or block.reference}]")
|
|
45
|
+
return "\n".join(parts)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_responses_input(messages: tuple[Message, ...]) -> ResponseInputParam:
|
|
49
|
+
items: list[object] = []
|
|
50
|
+
for message in messages:
|
|
51
|
+
text = _message_text(message)
|
|
52
|
+
if text:
|
|
53
|
+
items.append({"type": "message", "role": message.role.value, "content": text})
|
|
54
|
+
for block in message.content:
|
|
55
|
+
if isinstance(block, ToolCallBlock):
|
|
56
|
+
items.append(
|
|
57
|
+
{
|
|
58
|
+
"type": "function_call",
|
|
59
|
+
"call_id": block.call_id,
|
|
60
|
+
"name": block.name,
|
|
61
|
+
"arguments": json.dumps(block.arguments, separators=(",", ":")),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
elif isinstance(block, ToolResultBlock):
|
|
65
|
+
items.append(
|
|
66
|
+
{
|
|
67
|
+
"type": "function_call_output",
|
|
68
|
+
"call_id": block.call_id,
|
|
69
|
+
"output": block.content,
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
return cast(ResponseInputParam, items)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_responses_tools(request: ModelRequest) -> list[ToolParam]:
|
|
76
|
+
return [
|
|
77
|
+
cast(
|
|
78
|
+
ToolParam,
|
|
79
|
+
{
|
|
80
|
+
"type": "function",
|
|
81
|
+
"name": tool.name,
|
|
82
|
+
"description": tool.description,
|
|
83
|
+
"parameters": tool.parameters,
|
|
84
|
+
"strict": True,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
for tool in request.tools
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class OpenAIResponsesTransport(BaseTransport):
|
|
92
|
+
name = "openai_responses"
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
api_key: str | None = None,
|
|
98
|
+
base_url: str | None = None,
|
|
99
|
+
stream_factory: RawStreamFactory | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
super().__init__()
|
|
102
|
+
self._client: AsyncOpenAI | None = None
|
|
103
|
+
if stream_factory is None:
|
|
104
|
+
http_client = DefaultAioHttpClient()
|
|
105
|
+
self._client = AsyncOpenAI(
|
|
106
|
+
api_key=api_key,
|
|
107
|
+
base_url=base_url,
|
|
108
|
+
max_retries=0,
|
|
109
|
+
http_client=http_client,
|
|
110
|
+
)
|
|
111
|
+
self.add_close_callback(self._client.close)
|
|
112
|
+
self._stream_factory = self._sdk_stream
|
|
113
|
+
else:
|
|
114
|
+
self._stream_factory = stream_factory
|
|
115
|
+
|
|
116
|
+
async def _sdk_stream(self, request: ModelRequest) -> AsyncIterator[object]:
|
|
117
|
+
if self._client is None:
|
|
118
|
+
raise RuntimeError("OpenAI client is not configured")
|
|
119
|
+
stream = await self._client.responses.create(
|
|
120
|
+
model=request.model,
|
|
121
|
+
instructions=request.system_prompt,
|
|
122
|
+
input=build_responses_input(request.messages),
|
|
123
|
+
tools=build_responses_tools(request),
|
|
124
|
+
max_output_tokens=request.max_output_tokens,
|
|
125
|
+
stream=True,
|
|
126
|
+
store=False,
|
|
127
|
+
)
|
|
128
|
+
async for event in stream:
|
|
129
|
+
yield event
|
|
130
|
+
|
|
131
|
+
async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
|
|
132
|
+
self.ensure_open()
|
|
133
|
+
usage = Usage()
|
|
134
|
+
calls: dict[str, tuple[str, str]] = {}
|
|
135
|
+
completed = False
|
|
136
|
+
try:
|
|
137
|
+
async for event in self._stream_factory(request):
|
|
138
|
+
event_type = as_string(get_value(event, "type"))
|
|
139
|
+
if event_type == "response.output_text.delta":
|
|
140
|
+
yield TextDelta(as_string(get_value(event, "delta")))
|
|
141
|
+
elif event_type in {
|
|
142
|
+
"response.reasoning_summary_text.delta",
|
|
143
|
+
"response.reasoning_text.delta",
|
|
144
|
+
}:
|
|
145
|
+
yield ReasoningDelta(as_string(get_value(event, "delta")))
|
|
146
|
+
elif event_type == "response.output_item.added":
|
|
147
|
+
item = get_value(event, "item")
|
|
148
|
+
if as_string(get_value(item, "type")) == "function_call":
|
|
149
|
+
item_id = as_string(
|
|
150
|
+
get_value(item, "id", get_value(event, "output_index", ""))
|
|
151
|
+
)
|
|
152
|
+
call_id = as_string(get_value(item, "call_id", item_id))
|
|
153
|
+
name = as_string(get_value(item, "name"))
|
|
154
|
+
calls[item_id] = (call_id, name)
|
|
155
|
+
arguments = as_string(get_value(item, "arguments"))
|
|
156
|
+
yield ToolCallDelta(call_id, name, arguments)
|
|
157
|
+
elif event_type == "response.function_call_arguments.delta":
|
|
158
|
+
item_id = as_string(
|
|
159
|
+
get_value(event, "item_id", get_value(event, "output_index", ""))
|
|
160
|
+
)
|
|
161
|
+
call_id, name = calls.get(item_id, (item_id, ""))
|
|
162
|
+
yield ToolCallDelta(call_id, name, as_string(get_value(event, "delta")))
|
|
163
|
+
elif event_type in {"response.completed", "response.incomplete"}:
|
|
164
|
+
response = get_value(event, "response")
|
|
165
|
+
raw_usage = get_value(response, "usage")
|
|
166
|
+
input_details = get_value(raw_usage, "input_tokens_details")
|
|
167
|
+
usage = Usage(
|
|
168
|
+
input_tokens=as_int(get_value(raw_usage, "input_tokens")),
|
|
169
|
+
output_tokens=as_int(get_value(raw_usage, "output_tokens")),
|
|
170
|
+
cache_read_tokens=as_int(get_value(input_details, "cached_tokens")),
|
|
171
|
+
)
|
|
172
|
+
yield ModelUsage(usage)
|
|
173
|
+
if event_type == "response.incomplete":
|
|
174
|
+
reason = StopReason.MAX_TOKENS
|
|
175
|
+
elif calls:
|
|
176
|
+
reason = StopReason.TOOL_USE
|
|
177
|
+
else:
|
|
178
|
+
reason = StopReason.STOP
|
|
179
|
+
yield ModelCompleted(reason, usage)
|
|
180
|
+
completed = True
|
|
181
|
+
elif event_type == "response.failed":
|
|
182
|
+
response = get_value(event, "response")
|
|
183
|
+
error = get_value(response, "error", get_value(event, "error"))
|
|
184
|
+
message = as_string(get_value(error, "message"), "OpenAI response failed")
|
|
185
|
+
raise RuntimeError(message)
|
|
186
|
+
if not completed:
|
|
187
|
+
yield ModelCompleted(StopReason.STOP, usage)
|
|
188
|
+
except BaseException as exc:
|
|
189
|
+
raise map_provider_error(exc) from exc
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from windcode.auth import CredentialStore
|
|
8
|
+
from windcode.config.models import AppConfig, ProviderConfig, ProviderProtocol
|
|
9
|
+
from windcode.providers.anthropic import AnthropicTransport
|
|
10
|
+
from windcode.providers.base import ModelTransport
|
|
11
|
+
from windcode.providers.openai_compat import OpenAICompatibleTransport
|
|
12
|
+
from windcode.providers.openai_responses import OpenAIResponsesTransport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProviderConfigurationError(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class ModelTarget:
|
|
21
|
+
provider: str
|
|
22
|
+
model: str
|
|
23
|
+
transport: ModelTransport
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_transport(config: ProviderConfig, api_key: str) -> ModelTransport:
|
|
27
|
+
if config.protocol is ProviderProtocol.ANTHROPIC_MESSAGES:
|
|
28
|
+
return AnthropicTransport(api_key=api_key, base_url=config.base_url)
|
|
29
|
+
if config.protocol is ProviderProtocol.OPENAI_RESPONSES:
|
|
30
|
+
return OpenAIResponsesTransport(api_key=api_key, base_url=config.base_url)
|
|
31
|
+
if config.base_url is None:
|
|
32
|
+
raise ProviderConfigurationError("openai_compatible provider requires base_url")
|
|
33
|
+
return OpenAICompatibleTransport(api_key=api_key, base_url=config.base_url)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TransportRegistry:
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self._targets: dict[str, ModelTarget] = {}
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_config(
|
|
42
|
+
cls,
|
|
43
|
+
config: AppConfig,
|
|
44
|
+
*,
|
|
45
|
+
environ: Mapping[str, str] | None = None,
|
|
46
|
+
credential_store: CredentialStore | None = None,
|
|
47
|
+
allow_missing: bool = False,
|
|
48
|
+
) -> TransportRegistry:
|
|
49
|
+
registry = cls()
|
|
50
|
+
environment = os.environ if environ is None else environ
|
|
51
|
+
for alias, provider in config.providers.items():
|
|
52
|
+
api_key = environment.get(provider.api_key_env) if provider.api_key_env else None
|
|
53
|
+
if not api_key and provider.credential_id and credential_store is not None:
|
|
54
|
+
api_key = credential_store.get(provider.credential_id)
|
|
55
|
+
if not api_key:
|
|
56
|
+
if allow_missing:
|
|
57
|
+
continue
|
|
58
|
+
sources: list[str] = []
|
|
59
|
+
if provider.api_key_env:
|
|
60
|
+
sources.append(f"环境变量 {provider.api_key_env}")
|
|
61
|
+
if provider.credential_id:
|
|
62
|
+
sources.append(f"已保存凭据 {provider.credential_id}")
|
|
63
|
+
raise ProviderConfigurationError(
|
|
64
|
+
f"provider {alias!r} 缺少 API Key, 请设置{' 或更新'.join(sources)}"
|
|
65
|
+
)
|
|
66
|
+
registry.register(alias, provider.model, create_transport(provider, api_key))
|
|
67
|
+
return registry
|
|
68
|
+
|
|
69
|
+
def register(
|
|
70
|
+
self,
|
|
71
|
+
alias: str,
|
|
72
|
+
model: str,
|
|
73
|
+
transport: ModelTransport,
|
|
74
|
+
*,
|
|
75
|
+
replace: bool = False,
|
|
76
|
+
) -> None:
|
|
77
|
+
if alias in self._targets and not replace:
|
|
78
|
+
raise ValueError(f"transport alias already registered: {alias}")
|
|
79
|
+
self._targets[alias] = ModelTarget(alias, model, transport)
|
|
80
|
+
|
|
81
|
+
def get(self, alias: str) -> ModelTarget:
|
|
82
|
+
try:
|
|
83
|
+
return self._targets[alias]
|
|
84
|
+
except KeyError as exc:
|
|
85
|
+
raise KeyError(f"unknown transport alias: {alias}") from exc
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def aliases(self) -> tuple[str, ...]:
|
|
89
|
+
return tuple(self._targets)
|
|
90
|
+
|
|
91
|
+
def resolve_chain(self, config: AppConfig) -> tuple[ModelTarget, ...]:
|
|
92
|
+
if config.primary_provider is None:
|
|
93
|
+
raise ProviderConfigurationError("no primary_provider is configured")
|
|
94
|
+
aliases = (config.primary_provider, *config.fallback_chain)
|
|
95
|
+
if len(set(aliases)) != len(aliases):
|
|
96
|
+
raise ProviderConfigurationError("provider fallback chain contains a cycle")
|
|
97
|
+
return tuple(self.get(alias) for alias in aliases)
|
|
98
|
+
|
|
99
|
+
async def aclose(self) -> None:
|
|
100
|
+
closed: set[int] = set()
|
|
101
|
+
for target in reversed(tuple(self._targets.values())):
|
|
102
|
+
identity = id(target.transport)
|
|
103
|
+
if identity not in closed:
|
|
104
|
+
await target.transport.aclose()
|
|
105
|
+
closed.add(identity)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from windcode.runtime.control import BudgetExceeded, RunBudgets, RunControl
|
|
2
|
+
from windcode.runtime.event_bus import EventBus
|
|
3
|
+
from windcode.runtime.loop import AgentLoop
|
|
4
|
+
from windcode.runtime.scheduler import ScheduledCall, ScheduledResult, ToolScheduler
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AgentLoop",
|
|
8
|
+
"BudgetExceeded",
|
|
9
|
+
"EventBus",
|
|
10
|
+
"RunBudgets",
|
|
11
|
+
"RunControl",
|
|
12
|
+
"ScheduledCall",
|
|
13
|
+
"ScheduledResult",
|
|
14
|
+
"ToolScheduler",
|
|
15
|
+
]
|