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,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
|