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.
Files changed (143) hide show
  1. windcode/__init__.py +6 -0
  2. windcode/__main__.py +4 -0
  3. windcode/auth/__init__.py +11 -0
  4. windcode/auth/store.py +90 -0
  5. windcode/cli.py +181 -0
  6. windcode/config/__init__.py +41 -0
  7. windcode/config/loader.py +117 -0
  8. windcode/config/models.py +203 -0
  9. windcode/config/writer.py +74 -0
  10. windcode/context/__init__.py +18 -0
  11. windcode/context/compactor.py +72 -0
  12. windcode/context/estimator.py +89 -0
  13. windcode/context/truncation.py +87 -0
  14. windcode/domain/__init__.py +1 -0
  15. windcode/domain/errors.py +33 -0
  16. windcode/domain/events.py +597 -0
  17. windcode/domain/messages.py +226 -0
  18. windcode/domain/models.py +73 -0
  19. windcode/domain/subagents.py +263 -0
  20. windcode/domain/tools.py +55 -0
  21. windcode/extensions/__init__.py +27 -0
  22. windcode/extensions/commands.py +25 -0
  23. windcode/extensions/discovery.py +139 -0
  24. windcode/extensions/events.py +58 -0
  25. windcode/extensions/hooks/__init__.py +4 -0
  26. windcode/extensions/hooks/dispatcher.py +107 -0
  27. windcode/extensions/hooks/executor.py +49 -0
  28. windcode/extensions/hooks/loader.py +101 -0
  29. windcode/extensions/hooks/models.py +109 -0
  30. windcode/extensions/mcp/__init__.py +10 -0
  31. windcode/extensions/mcp/adapter.py +101 -0
  32. windcode/extensions/mcp/catalog.py +153 -0
  33. windcode/extensions/mcp/client.py +273 -0
  34. windcode/extensions/mcp/runtime.py +144 -0
  35. windcode/extensions/mcp/tools.py +570 -0
  36. windcode/extensions/models.py +168 -0
  37. windcode/extensions/paths.py +71 -0
  38. windcode/extensions/plugins/__init__.py +3 -0
  39. windcode/extensions/plugins/installer.py +93 -0
  40. windcode/extensions/plugins/manifest.py +166 -0
  41. windcode/extensions/runtime.py +366 -0
  42. windcode/extensions/service.py +437 -0
  43. windcode/extensions/skills/__init__.py +3 -0
  44. windcode/extensions/skills/loader.py +67 -0
  45. windcode/extensions/skills/parser.py +57 -0
  46. windcode/extensions/skills/tools.py +213 -0
  47. windcode/extensions/snapshot.py +57 -0
  48. windcode/extensions/state.py +158 -0
  49. windcode/instructions/__init__.py +8 -0
  50. windcode/instructions/loader.py +50 -0
  51. windcode/memory/__init__.py +57 -0
  52. windcode/memory/extraction.py +83 -0
  53. windcode/memory/models.py +218 -0
  54. windcode/memory/refiner.py +189 -0
  55. windcode/memory/security.py +23 -0
  56. windcode/memory/service.py +179 -0
  57. windcode/memory/store.py +362 -0
  58. windcode/observability/__init__.py +4 -0
  59. windcode/observability/redaction.py +95 -0
  60. windcode/observability/trace.py +115 -0
  61. windcode/policy/__init__.py +22 -0
  62. windcode/policy/engine.py +137 -0
  63. windcode/policy/models.py +69 -0
  64. windcode/providers/__init__.py +29 -0
  65. windcode/providers/_utils.py +19 -0
  66. windcode/providers/anthropic.py +215 -0
  67. windcode/providers/base.py +46 -0
  68. windcode/providers/catalog.py +119 -0
  69. windcode/providers/errors.py +96 -0
  70. windcode/providers/openai_compat.py +231 -0
  71. windcode/providers/openai_responses.py +189 -0
  72. windcode/providers/registry.py +105 -0
  73. windcode/runtime/__init__.py +15 -0
  74. windcode/runtime/control.py +89 -0
  75. windcode/runtime/event_bus.py +67 -0
  76. windcode/runtime/loop.py +510 -0
  77. windcode/runtime/prompts.py +132 -0
  78. windcode/runtime/report.py +64 -0
  79. windcode/runtime/retry.py +73 -0
  80. windcode/runtime/scheduler.py +194 -0
  81. windcode/runtime/subagents/__init__.py +28 -0
  82. windcode/runtime/subagents/approvals.py +88 -0
  83. windcode/runtime/subagents/budgets.py +79 -0
  84. windcode/runtime/subagents/coordinator.py +714 -0
  85. windcode/runtime/subagents/factory.py +311 -0
  86. windcode/runtime/subagents/roles.py +74 -0
  87. windcode/runtime/subagents/verification.py +53 -0
  88. windcode/sandbox/__init__.py +3 -0
  89. windcode/sandbox/bwrap.py +79 -0
  90. windcode/sdk.py +1259 -0
  91. windcode/sessions/__init__.py +23 -0
  92. windcode/sessions/artifacts.py +69 -0
  93. windcode/sessions/models.py +104 -0
  94. windcode/sessions/store.py +167 -0
  95. windcode/sessions/tree.py +35 -0
  96. windcode/tools/__init__.py +10 -0
  97. windcode/tools/apply_patch.py +191 -0
  98. windcode/tools/ask_user.py +58 -0
  99. windcode/tools/builtins.py +44 -0
  100. windcode/tools/edit_file.py +54 -0
  101. windcode/tools/filesystem.py +77 -0
  102. windcode/tools/glob.py +35 -0
  103. windcode/tools/grep.py +66 -0
  104. windcode/tools/memory.py +400 -0
  105. windcode/tools/read_file.py +54 -0
  106. windcode/tools/registry.py +120 -0
  107. windcode/tools/shell.py +159 -0
  108. windcode/tools/subagents/__init__.py +31 -0
  109. windcode/tools/subagents/cancel.py +35 -0
  110. windcode/tools/subagents/integrate.py +54 -0
  111. windcode/tools/subagents/list.py +46 -0
  112. windcode/tools/subagents/spawn.py +86 -0
  113. windcode/tools/subagents/wait.py +74 -0
  114. windcode/tools/write_file.py +61 -0
  115. windcode/tui/__init__.py +3 -0
  116. windcode/tui/app.py +1015 -0
  117. windcode/tui/commands.py +90 -0
  118. windcode/tui/permission_display.py +24 -0
  119. windcode/tui/styles.tcss +591 -0
  120. windcode/tui/widgets/__init__.py +31 -0
  121. windcode/tui/widgets/approval.py +98 -0
  122. windcode/tui/widgets/command_menu.py +90 -0
  123. windcode/tui/widgets/extensions.py +48 -0
  124. windcode/tui/widgets/input.py +110 -0
  125. windcode/tui/widgets/memory.py +143 -0
  126. windcode/tui/widgets/messages.py +299 -0
  127. windcode/tui/widgets/models.py +565 -0
  128. windcode/tui/widgets/question.py +46 -0
  129. windcode/tui/widgets/sessions.py +68 -0
  130. windcode/tui/widgets/status.py +46 -0
  131. windcode/tui/widgets/subagents.py +195 -0
  132. windcode/tui/widgets/tools.py +66 -0
  133. windcode/tui/widgets/welcome.py +149 -0
  134. windcode/types.py +80 -0
  135. windcode/worktrees/__init__.py +24 -0
  136. windcode/worktrees/git.py +92 -0
  137. windcode/worktrees/manager.py +265 -0
  138. windcode/worktrees/models.py +62 -0
  139. windcode-0.1.0.dist-info/METADATA +207 -0
  140. windcode-0.1.0.dist-info/RECORD +143 -0
  141. windcode-0.1.0.dist-info/WHEEL +4 -0
  142. windcode-0.1.0.dist-info/entry_points.txt +2 -0
  143. 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
+ ]