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,137 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from windcode.config.models import PermissionMode
6
+ from windcode.domain.tools import ToolEffect
7
+ from windcode.policy.models import (
8
+ ApprovalChoice,
9
+ PolicyAction,
10
+ PolicyDecision,
11
+ PolicyRequest,
12
+ RiskLevel,
13
+ )
14
+
15
+ _DANGEROUS_COMMAND = re.compile(
16
+ r"(?:^|[;&|]\s*)(?:sudo\b|rm\s+-[^\n]*r[^\n]*f|mkfs\b|dd\s+if=|shutdown\b|reboot\b)"
17
+ )
18
+
19
+
20
+ def assess_risk(request: PolicyRequest) -> RiskLevel:
21
+ if ToolEffect.OUTSIDE_WORKSPACE in request.effects:
22
+ return RiskLevel.CRITICAL
23
+ if request.command and _DANGEROUS_COMMAND.search(request.command):
24
+ return RiskLevel.CRITICAL
25
+ if ToolEffect.NETWORK in request.effects:
26
+ return RiskLevel.HIGH
27
+ if ToolEffect.PROCESS in request.effects:
28
+ return RiskLevel.MEDIUM
29
+ return RiskLevel.LOW
30
+
31
+
32
+ def _approval(reason: str, risk: RiskLevel) -> PolicyDecision:
33
+ return PolicyDecision(
34
+ action=PolicyAction.ASK,
35
+ risk=risk,
36
+ reason=reason,
37
+ choices=(
38
+ ApprovalChoice.ALLOW_ONCE,
39
+ ApprovalChoice.ALLOW_SESSION,
40
+ ApprovalChoice.DENY,
41
+ ),
42
+ )
43
+
44
+
45
+ class PolicyEngine:
46
+ def __init__(
47
+ self,
48
+ mode: PermissionMode,
49
+ *,
50
+ sandbox_enabled: bool = True,
51
+ sandbox_available: bool = True,
52
+ ) -> None:
53
+ self.mode = mode
54
+ self.sandbox_enabled = sandbox_enabled
55
+ self.sandbox_available = sandbox_available
56
+ self._session_approvals: set[tuple[str, frozenset[ToolEffect]]] = set()
57
+
58
+ @staticmethod
59
+ def _fingerprint(request: PolicyRequest) -> tuple[str, frozenset[ToolEffect]]:
60
+ return request.tool_name, request.effects
61
+
62
+ def approve_for_session(self, request: PolicyRequest) -> None:
63
+ self._session_approvals.add(self._fingerprint(request))
64
+
65
+ def restore_session_approval(
66
+ self,
67
+ tool_name: str,
68
+ effects: frozenset[ToolEffect],
69
+ ) -> None:
70
+ self._session_approvals.add((tool_name, effects))
71
+
72
+ def set_mode(self, mode: PermissionMode) -> None:
73
+ self.mode = mode
74
+
75
+ def evaluate(self, request: PolicyRequest) -> PolicyDecision:
76
+ risk = assess_risk(request)
77
+ effects = request.effects
78
+ side_effects = {
79
+ ToolEffect.WORKSPACE_WRITE,
80
+ ToolEffect.PROCESS,
81
+ ToolEffect.NETWORK,
82
+ ToolEffect.OUTSIDE_WORKSPACE,
83
+ }
84
+ if self.mode is PermissionMode.PLAN:
85
+ if effects & side_effects:
86
+ return PolicyDecision(
87
+ action=PolicyAction.DENY,
88
+ risk=risk,
89
+ reason="plan mode does not permit side effects",
90
+ )
91
+ return PolicyDecision(
92
+ action=PolicyAction.ALLOW,
93
+ risk=risk,
94
+ reason="plan mode permits reading and user interaction",
95
+ )
96
+
97
+ if self._fingerprint(request) in self._session_approvals:
98
+ return PolicyDecision(
99
+ action=PolicyAction.ALLOW,
100
+ risk=risk,
101
+ reason="matching operation was approved for this session",
102
+ )
103
+
104
+ sandbox_degraded = (
105
+ ToolEffect.PROCESS in effects and self.sandbox_enabled and not self.sandbox_available
106
+ )
107
+ if sandbox_degraded:
108
+ return _approval("system sandbox is unavailable; explicit approval is required", risk)
109
+
110
+ if self.mode is PermissionMode.DEFAULT:
111
+ if effects & side_effects:
112
+ return _approval("default mode requires approval for side effects", risk)
113
+ return PolicyDecision(
114
+ action=PolicyAction.ALLOW,
115
+ risk=risk,
116
+ reason="read-only operation is allowed",
117
+ )
118
+
119
+ if self.mode is PermissionMode.ACCEPT_EDITS:
120
+ disallowed = effects & {
121
+ ToolEffect.PROCESS,
122
+ ToolEffect.NETWORK,
123
+ ToolEffect.OUTSIDE_WORKSPACE,
124
+ }
125
+ if disallowed or risk is RiskLevel.CRITICAL:
126
+ return _approval("operation exceeds automatic workspace edit permission", risk)
127
+ return PolicyDecision(
128
+ action=PolicyAction.ALLOW,
129
+ risk=risk,
130
+ reason="workspace edits are allowed in accept_edits mode",
131
+ )
132
+
133
+ return PolicyDecision(
134
+ action=PolicyAction.ALLOW,
135
+ risk=risk,
136
+ reason="full_access mode was explicitly selected",
137
+ )
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from windcode.config.models import PermissionMode
8
+ from windcode.domain.tools import ToolEffect
9
+
10
+
11
+ class PolicyModel(BaseModel):
12
+ model_config = ConfigDict(extra="forbid", frozen=True)
13
+
14
+
15
+ class RiskLevel(StrEnum):
16
+ LOW = "low"
17
+ MEDIUM = "medium"
18
+ HIGH = "high"
19
+ CRITICAL = "critical"
20
+
21
+
22
+ class PolicyAction(StrEnum):
23
+ ALLOW = "allow"
24
+ DENY = "deny"
25
+ ASK = "ask"
26
+
27
+
28
+ class ApprovalChoice(StrEnum):
29
+ ALLOW_ONCE = "allow_once"
30
+ ALLOW_SESSION = "allow_session"
31
+ DENY = "deny"
32
+
33
+
34
+ class PolicyRequest(PolicyModel):
35
+ request_id: str = Field(min_length=1)
36
+ call_id: str = Field(min_length=1)
37
+ tool_name: str = Field(min_length=1)
38
+ effects: frozenset[ToolEffect]
39
+ summary: str = Field(min_length=1)
40
+ path: str | None = None
41
+ command: str | None = None
42
+
43
+
44
+ class PolicyDecision(PolicyModel):
45
+ action: PolicyAction
46
+ risk: RiskLevel
47
+ reason: str
48
+ choices: tuple[ApprovalChoice, ...] = ()
49
+
50
+
51
+ def summarize_policy_arguments(request: PolicyRequest, *, limit: int = 200) -> str | None:
52
+ value = request.command or request.path
53
+ if value is None:
54
+ return None
55
+ summary = " ".join(value.split())
56
+ if len(summary) <= limit:
57
+ return summary
58
+ return summary[: limit - 3].rstrip() + "..."
59
+
60
+
61
+ __all__ = [
62
+ "ApprovalChoice",
63
+ "PermissionMode",
64
+ "PolicyAction",
65
+ "PolicyDecision",
66
+ "PolicyRequest",
67
+ "RiskLevel",
68
+ "summarize_policy_arguments",
69
+ ]
@@ -0,0 +1,29 @@
1
+ from windcode.providers.anthropic import AnthropicTransport
2
+ from windcode.providers.base import BaseTransport, ModelTransport
3
+ from windcode.providers.catalog import PROVIDER_PRESETS, ProviderPreset, provider_preset
4
+ from windcode.providers.errors import ProviderError, map_provider_error
5
+ from windcode.providers.openai_compat import OpenAICompatibleTransport
6
+ from windcode.providers.openai_responses import OpenAIResponsesTransport
7
+ from windcode.providers.registry import (
8
+ ModelTarget,
9
+ ProviderConfigurationError,
10
+ TransportRegistry,
11
+ create_transport,
12
+ )
13
+
14
+ __all__ = [
15
+ "PROVIDER_PRESETS",
16
+ "AnthropicTransport",
17
+ "BaseTransport",
18
+ "ModelTarget",
19
+ "ModelTransport",
20
+ "OpenAICompatibleTransport",
21
+ "OpenAIResponsesTransport",
22
+ "ProviderConfigurationError",
23
+ "ProviderError",
24
+ "ProviderPreset",
25
+ "TransportRegistry",
26
+ "create_transport",
27
+ "map_provider_error",
28
+ "provider_preset",
29
+ ]
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import cast
5
+
6
+
7
+ def get_value(value: object, name: str, default: object = None) -> object:
8
+ if isinstance(value, Mapping):
9
+ mapping = cast(Mapping[object, object], value)
10
+ return mapping.get(name, default)
11
+ return getattr(value, name, default)
12
+
13
+
14
+ def as_string(value: object, default: str = "") -> str:
15
+ return value if isinstance(value, str) else default
16
+
17
+
18
+ def as_int(value: object, default: int = 0) -> int:
19
+ return value if isinstance(value, int) and not isinstance(value, bool) else default
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator, Callable
5
+ from typing import cast
6
+
7
+ from anthropic import AsyncAnthropic, DefaultAioHttpClient
8
+ from anthropic.types import MessageParam, ToolUnionParam
9
+
10
+ from windcode.domain.messages import (
11
+ AttachmentBlock,
12
+ Message,
13
+ ReasoningBlock,
14
+ Role,
15
+ TextBlock,
16
+ ToolCallBlock,
17
+ ToolResultBlock,
18
+ )
19
+ from windcode.domain.models import (
20
+ ModelCompleted,
21
+ ModelEvent,
22
+ ModelRequest,
23
+ ModelUsage,
24
+ ReasoningDelta,
25
+ StopReason,
26
+ TextDelta,
27
+ ToolCallDelta,
28
+ Usage,
29
+ )
30
+ from windcode.providers._utils import as_int, as_string, get_value
31
+ from windcode.providers.base import BaseTransport
32
+ from windcode.providers.errors import map_provider_error
33
+
34
+ RawStreamFactory = Callable[[ModelRequest], AsyncIterator[object]]
35
+
36
+
37
+ def _content_block(block: object) -> dict[str, object]:
38
+ if isinstance(block, TextBlock):
39
+ return {"type": "text", "text": block.text}
40
+ if isinstance(block, ReasoningBlock):
41
+ return {"type": "text", "text": block.summary}
42
+ if isinstance(block, ToolCallBlock):
43
+ return {
44
+ "type": "tool_use",
45
+ "id": block.call_id,
46
+ "name": block.name,
47
+ "input": block.arguments,
48
+ }
49
+ if isinstance(block, ToolResultBlock):
50
+ return {
51
+ "type": "tool_result",
52
+ "tool_use_id": block.call_id,
53
+ "content": block.content,
54
+ "is_error": block.is_error,
55
+ }
56
+ if isinstance(block, AttachmentBlock):
57
+ return {
58
+ "type": "text",
59
+ "text": f"[attachment: {block.description or block.reference}]",
60
+ }
61
+ raise TypeError(f"unsupported content block: {type(block).__name__}")
62
+
63
+
64
+ def build_anthropic_messages(messages: tuple[Message, ...]) -> list[MessageParam]:
65
+ converted: list[MessageParam] = []
66
+ for message in messages:
67
+ if message.role is Role.SYSTEM:
68
+ continue
69
+ role = "assistant" if message.role is Role.ASSISTANT else "user"
70
+ converted.append(
71
+ cast(
72
+ MessageParam,
73
+ {"role": role, "content": [_content_block(block) for block in message.content]},
74
+ )
75
+ )
76
+ return converted
77
+
78
+
79
+ def build_anthropic_tools(request: ModelRequest) -> list[ToolUnionParam]:
80
+ return [
81
+ cast(
82
+ ToolUnionParam,
83
+ {
84
+ "name": tool.name,
85
+ "description": tool.description,
86
+ "input_schema": tool.parameters,
87
+ },
88
+ )
89
+ for tool in request.tools
90
+ ]
91
+
92
+
93
+ def _stop_reason(value: object) -> StopReason:
94
+ raw = as_string(value)
95
+ return {
96
+ "end_turn": StopReason.STOP,
97
+ "stop_sequence": StopReason.STOP,
98
+ "tool_use": StopReason.TOOL_USE,
99
+ "max_tokens": StopReason.MAX_TOKENS,
100
+ "refusal": StopReason.CONTENT_FILTER,
101
+ }.get(raw, StopReason.STOP)
102
+
103
+
104
+ class AnthropicTransport(BaseTransport):
105
+ name = "anthropic_messages"
106
+
107
+ def __init__(
108
+ self,
109
+ *,
110
+ api_key: str | None = None,
111
+ base_url: str | None = None,
112
+ stream_factory: RawStreamFactory | None = None,
113
+ ) -> None:
114
+ super().__init__()
115
+ self._client: AsyncAnthropic | None = None
116
+ if stream_factory is None:
117
+ http_client = DefaultAioHttpClient()
118
+ self._client = AsyncAnthropic(
119
+ api_key=api_key,
120
+ base_url=base_url,
121
+ max_retries=0,
122
+ http_client=http_client,
123
+ )
124
+ self.add_close_callback(self._client.close)
125
+ self._stream_factory = self._sdk_stream
126
+ else:
127
+ self._stream_factory = stream_factory
128
+
129
+ async def _sdk_stream(self, request: ModelRequest) -> AsyncIterator[object]:
130
+ if self._client is None:
131
+ raise RuntimeError("Anthropic client is not configured")
132
+ stream = await self._client.messages.create(
133
+ model=request.model,
134
+ messages=build_anthropic_messages(request.messages),
135
+ system=request.system_prompt,
136
+ tools=build_anthropic_tools(request),
137
+ max_tokens=request.max_output_tokens or 4096,
138
+ stream=True,
139
+ )
140
+ async for event in stream:
141
+ yield event
142
+
143
+ def _convert_event(self, event: object, usage: Usage) -> tuple[list[ModelEvent], Usage, bool]:
144
+ event_type = as_string(get_value(event, "type"))
145
+ emitted: list[ModelEvent] = []
146
+ completed = False
147
+ if event_type == "message_start":
148
+ raw_usage = get_value(get_value(event, "message"), "usage")
149
+ usage = Usage(
150
+ input_tokens=as_int(get_value(raw_usage, "input_tokens")),
151
+ output_tokens=usage.output_tokens,
152
+ cache_read_tokens=as_int(get_value(raw_usage, "cache_read_input_tokens")),
153
+ cache_write_tokens=as_int(get_value(raw_usage, "cache_creation_input_tokens")),
154
+ )
155
+ emitted.append(ModelUsage(usage))
156
+ elif event_type == "content_block_start":
157
+ block = get_value(event, "content_block")
158
+ if as_string(get_value(block, "type")) == "tool_use":
159
+ initial = get_value(block, "input", {})
160
+ arguments = "" if initial == {} else json.dumps(initial, separators=(",", ":"))
161
+ emitted.append(
162
+ ToolCallDelta(
163
+ call_id=as_string(get_value(block, "id")),
164
+ name=as_string(get_value(block, "name")),
165
+ arguments_delta=arguments,
166
+ )
167
+ )
168
+ elif event_type == "content_block_delta":
169
+ delta = get_value(event, "delta")
170
+ delta_type = as_string(get_value(delta, "type"))
171
+ if delta_type == "text_delta":
172
+ emitted.append(TextDelta(as_string(get_value(delta, "text"))))
173
+ elif delta_type in {"thinking_delta", "signature_delta"}:
174
+ summary = as_string(get_value(delta, "thinking", get_value(delta, "signature", "")))
175
+ emitted.append(ReasoningDelta(summary))
176
+ elif delta_type == "input_json_delta":
177
+ emitted.append(
178
+ ToolCallDelta(
179
+ call_id="",
180
+ name="",
181
+ arguments_delta=as_string(get_value(delta, "partial_json")),
182
+ )
183
+ )
184
+ elif event_type == "message_delta":
185
+ raw_usage = get_value(event, "usage")
186
+ usage = Usage(
187
+ input_tokens=usage.input_tokens,
188
+ output_tokens=as_int(get_value(raw_usage, "output_tokens"), usage.output_tokens),
189
+ cache_read_tokens=usage.cache_read_tokens,
190
+ cache_write_tokens=usage.cache_write_tokens,
191
+ )
192
+ emitted.append(ModelUsage(usage))
193
+ emitted.append(
194
+ ModelCompleted(
195
+ reason=_stop_reason(get_value(get_value(event, "delta"), "stop_reason")),
196
+ usage=usage,
197
+ )
198
+ )
199
+ completed = True
200
+ return emitted, usage, completed
201
+
202
+ async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
203
+ self.ensure_open()
204
+ usage = Usage()
205
+ completed = False
206
+ try:
207
+ async for raw_event in self._stream_factory(request):
208
+ events, usage, just_completed = self._convert_event(raw_event, usage)
209
+ completed = completed or just_completed
210
+ for event in events:
211
+ yield event
212
+ if not completed:
213
+ yield ModelCompleted(StopReason.STOP, usage)
214
+ except BaseException as exc:
215
+ raise map_provider_error(exc) from exc
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import AsyncIterator, Awaitable, Callable
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ from windcode.domain.models import ModelEvent, ModelRequest
8
+
9
+
10
+ @runtime_checkable
11
+ class ModelTransport(Protocol):
12
+ name: str
13
+
14
+ def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]: ...
15
+
16
+ async def aclose(self) -> None: ...
17
+
18
+
19
+ class BaseTransport(ABC):
20
+ name: str
21
+
22
+ def __init__(self) -> None:
23
+ self._close_callbacks: list[Callable[[], Awaitable[None]]] = []
24
+ self._closed = False
25
+
26
+ @property
27
+ def closed(self) -> bool:
28
+ return self._closed
29
+
30
+ def add_close_callback(self, callback: Callable[[], Awaitable[None]]) -> None:
31
+ self._close_callbacks.append(callback)
32
+
33
+ @abstractmethod
34
+ def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
35
+ raise NotImplementedError
36
+
37
+ async def aclose(self) -> None:
38
+ if self._closed:
39
+ return
40
+ self._closed = True
41
+ for callback in reversed(self._close_callbacks):
42
+ await callback()
43
+
44
+ def ensure_open(self) -> None:
45
+ if self._closed:
46
+ raise RuntimeError(f"transport {self.name} is closed")
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from windcode.config.models import ProviderConfig, ProviderProtocol
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class ProviderPreset:
10
+ id: str
11
+ name: str
12
+ protocol: ProviderProtocol
13
+ api_key_env: str
14
+ base_url: str
15
+
16
+
17
+ PROVIDER_PRESETS: tuple[ProviderPreset, ...] = (
18
+ ProviderPreset(
19
+ "openai",
20
+ "OpenAI",
21
+ ProviderProtocol.OPENAI_RESPONSES,
22
+ "OPENAI_API_KEY",
23
+ "https://api.openai.com/v1",
24
+ ),
25
+ ProviderPreset(
26
+ "anthropic",
27
+ "Anthropic",
28
+ ProviderProtocol.ANTHROPIC_MESSAGES,
29
+ "ANTHROPIC_API_KEY",
30
+ "https://api.anthropic.com",
31
+ ),
32
+ ProviderPreset(
33
+ "deepseek",
34
+ "DeepSeek",
35
+ ProviderProtocol.OPENAI_COMPATIBLE,
36
+ "DEEPSEEK_API_KEY",
37
+ "https://api.deepseek.com/v1",
38
+ ),
39
+ ProviderPreset(
40
+ "moonshotai",
41
+ "Moonshot AI",
42
+ ProviderProtocol.OPENAI_COMPATIBLE,
43
+ "MOONSHOT_API_KEY",
44
+ "https://api.moonshot.ai/v1",
45
+ ),
46
+ ProviderPreset(
47
+ "siliconflow",
48
+ "SiliconFlow",
49
+ ProviderProtocol.OPENAI_COMPATIBLE,
50
+ "SILICONFLOW_API_KEY",
51
+ "https://api.siliconflow.com/v1",
52
+ ),
53
+ ProviderPreset(
54
+ "openrouter",
55
+ "OpenRouter",
56
+ ProviderProtocol.OPENAI_COMPATIBLE,
57
+ "OPENROUTER_API_KEY",
58
+ "https://openrouter.ai/api/v1",
59
+ ),
60
+ ProviderPreset(
61
+ "zhipuai",
62
+ "Zhipu AI",
63
+ ProviderProtocol.OPENAI_COMPATIBLE,
64
+ "ZHIPU_API_KEY",
65
+ "https://open.bigmodel.cn/api/paas/v4",
66
+ ),
67
+ ProviderPreset(
68
+ "alibaba",
69
+ "Alibaba Cloud",
70
+ ProviderProtocol.OPENAI_COMPATIBLE,
71
+ "DASHSCOPE_API_KEY",
72
+ "https://dashscope.aliyuncs.com/compatible-mode/v1",
73
+ ),
74
+ ProviderPreset(
75
+ "groq",
76
+ "Groq",
77
+ ProviderProtocol.OPENAI_COMPATIBLE,
78
+ "GROQ_API_KEY",
79
+ "https://api.groq.com/openai/v1",
80
+ ),
81
+ ProviderPreset(
82
+ "mistral",
83
+ "Mistral",
84
+ ProviderProtocol.OPENAI_COMPATIBLE,
85
+ "MISTRAL_API_KEY",
86
+ "https://api.mistral.ai/v1",
87
+ ),
88
+ ProviderPreset(
89
+ "xai", "xAI", ProviderProtocol.OPENAI_COMPATIBLE, "XAI_API_KEY", "https://api.x.ai/v1"
90
+ ),
91
+ ProviderPreset(
92
+ "google",
93
+ "Google Gemini",
94
+ ProviderProtocol.OPENAI_COMPATIBLE,
95
+ "GEMINI_API_KEY",
96
+ "https://generativelanguage.googleapis.com/v1beta/openai",
97
+ ),
98
+ )
99
+
100
+ PRESETS_BY_ID = {preset.id: preset for preset in PROVIDER_PRESETS}
101
+
102
+
103
+ def provider_preset(config: ProviderConfig) -> ProviderPreset | None:
104
+ if config.provider_id is not None:
105
+ preset = PRESETS_BY_ID.get(config.provider_id)
106
+ if preset is not None:
107
+ return preset
108
+ normalized_url = (config.base_url or "").rstrip("/").casefold()
109
+ for preset in PROVIDER_PRESETS:
110
+ if (
111
+ config.protocol is preset.protocol
112
+ and normalized_url == preset.base_url.rstrip("/").casefold()
113
+ ):
114
+ return preset
115
+ if config.protocol is ProviderProtocol.OPENAI_RESPONSES:
116
+ return PRESETS_BY_ID["openai"]
117
+ if config.protocol is ProviderProtocol.ANTHROPIC_MESSAGES:
118
+ return PRESETS_BY_ID["anthropic"]
119
+ return None