pulse-coding-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
pulse/provider_keys.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Secure provider-key management for the Pulse CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pulse.providers.manager import PROVIDER_SPECS
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import keyring
|
|
16
|
+
except ImportError: # pragma: no cover - the release dependency is mandatory
|
|
17
|
+
keyring = None # type: ignore[assignment]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
KEYRING_SERVICE_NAME = "pulse-coding-agent.provider-keys"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProviderKeyError(ValueError):
|
|
24
|
+
"""Raised when a provider key cannot be safely updated."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ProviderKeyStatus:
|
|
29
|
+
provider: str
|
|
30
|
+
environment_variable: str
|
|
31
|
+
configured: bool
|
|
32
|
+
source: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProviderKeyStore:
|
|
36
|
+
"""Manage provider keys without accepting secrets as command arguments.
|
|
37
|
+
|
|
38
|
+
New keys are stored in the native OS credential vault and scoped to the
|
|
39
|
+
current workspace. Existing environment and ``.env`` credentials remain
|
|
40
|
+
readable for compatibility, but a set/rotation migrates that provider away
|
|
41
|
+
from plaintext workspace storage.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, workspace: Path) -> None:
|
|
45
|
+
self.workspace = workspace.resolve()
|
|
46
|
+
self.env_path = self.workspace / ".env"
|
|
47
|
+
workspace_digest = hashlib.sha256(
|
|
48
|
+
os.path.normcase(str(self.workspace)).encode("utf-8")
|
|
49
|
+
).hexdigest()[:24]
|
|
50
|
+
self._account_prefix = f"workspace:{workspace_digest}"
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _spec(provider: str):
|
|
54
|
+
normalized = provider.strip().lower()
|
|
55
|
+
try:
|
|
56
|
+
return PROVIDER_SPECS[normalized]
|
|
57
|
+
except KeyError as error:
|
|
58
|
+
supported = ", ".join(PROVIDER_SPECS)
|
|
59
|
+
raise ProviderKeyError(
|
|
60
|
+
f"Unsupported provider '{provider}'. Choose one of: {supported}."
|
|
61
|
+
) from error
|
|
62
|
+
|
|
63
|
+
def statuses(self) -> tuple[ProviderKeyStatus, ...]:
|
|
64
|
+
workspace_values = self._workspace_values()
|
|
65
|
+
result: list[ProviderKeyStatus] = []
|
|
66
|
+
for spec in PROVIDER_SPECS.values():
|
|
67
|
+
workspace_value = workspace_values.get(spec.env_var, "").strip()
|
|
68
|
+
environment_value = os.environ.get(spec.env_var, "").strip()
|
|
69
|
+
vault_value = self._vault_get(spec.key)
|
|
70
|
+
if self._usable(vault_value):
|
|
71
|
+
configured, source = True, "OS credential vault"
|
|
72
|
+
elif self._usable(workspace_value):
|
|
73
|
+
configured, source = True, "legacy workspace .env"
|
|
74
|
+
elif self._usable(environment_value):
|
|
75
|
+
configured, source = True, "environment"
|
|
76
|
+
else:
|
|
77
|
+
configured, source = False, "not configured"
|
|
78
|
+
result.append(
|
|
79
|
+
ProviderKeyStatus(
|
|
80
|
+
provider=spec.key,
|
|
81
|
+
environment_variable=spec.env_var,
|
|
82
|
+
configured=configured,
|
|
83
|
+
source=source,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return tuple(result)
|
|
87
|
+
|
|
88
|
+
def get(self, provider: str) -> str | None:
|
|
89
|
+
"""Resolve a provider secret without exposing it through status APIs."""
|
|
90
|
+
spec = self._spec(provider)
|
|
91
|
+
vault_value = self._vault_get(spec.key)
|
|
92
|
+
if self._usable(vault_value):
|
|
93
|
+
return vault_value
|
|
94
|
+
environment_value = os.environ.get(spec.env_var, "")
|
|
95
|
+
if self._usable(environment_value):
|
|
96
|
+
return environment_value.strip()
|
|
97
|
+
workspace_value = self._workspace_values().get(spec.env_var, "")
|
|
98
|
+
return workspace_value.strip() if self._usable(workspace_value) else None
|
|
99
|
+
|
|
100
|
+
def set(self, provider: str, value: str) -> str:
|
|
101
|
+
spec = self._spec(provider)
|
|
102
|
+
legacy_value = self._workspace_values().get(spec.env_var)
|
|
103
|
+
previous_vault_value = self._vault_get(spec.key)
|
|
104
|
+
normalized = value.strip()
|
|
105
|
+
if not self._usable(normalized) or "\n" in value or "\r" in value:
|
|
106
|
+
raise ProviderKeyError("API key must be a non-placeholder, single-line value.")
|
|
107
|
+
if keyring is None:
|
|
108
|
+
raise ProviderKeyError(
|
|
109
|
+
"The OS credential vault is unavailable; the API key was not stored."
|
|
110
|
+
)
|
|
111
|
+
try:
|
|
112
|
+
keyring.set_password(
|
|
113
|
+
KEYRING_SERVICE_NAME,
|
|
114
|
+
self._account(spec.key),
|
|
115
|
+
normalized,
|
|
116
|
+
)
|
|
117
|
+
persisted = keyring.get_password(
|
|
118
|
+
KEYRING_SERVICE_NAME,
|
|
119
|
+
self._account(spec.key),
|
|
120
|
+
)
|
|
121
|
+
except Exception as error:
|
|
122
|
+
raise ProviderKeyError(
|
|
123
|
+
"The OS credential vault rejected the API key; nothing was stored. "
|
|
124
|
+
"Check the native keyring configuration and try again."
|
|
125
|
+
) from error
|
|
126
|
+
if not persisted or not hmac.compare_digest(persisted, normalized):
|
|
127
|
+
raise ProviderKeyError(
|
|
128
|
+
"The OS credential vault did not confirm persistence; the API key "
|
|
129
|
+
"was not accepted."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Successful set/rotation is also an explicit migration away from the
|
|
133
|
+
# legacy plaintext workspace entry. Never remove it before persistence
|
|
134
|
+
# has been verified.
|
|
135
|
+
if legacy_value is not None:
|
|
136
|
+
try:
|
|
137
|
+
self._rewrite(spec.env_var, None)
|
|
138
|
+
except ProviderKeyError as migration_error:
|
|
139
|
+
try:
|
|
140
|
+
if previous_vault_value is None:
|
|
141
|
+
keyring.delete_password(
|
|
142
|
+
KEYRING_SERVICE_NAME,
|
|
143
|
+
self._account(spec.key),
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
keyring.set_password(
|
|
147
|
+
KEYRING_SERVICE_NAME,
|
|
148
|
+
self._account(spec.key),
|
|
149
|
+
previous_vault_value,
|
|
150
|
+
)
|
|
151
|
+
except Exception as rollback_error:
|
|
152
|
+
raise ProviderKeyError(
|
|
153
|
+
"The key reached the OS credential vault, but legacy .env "
|
|
154
|
+
"cleanup and vault rollback both failed. The secret was not "
|
|
155
|
+
"displayed; resolve storage permissions before retrying."
|
|
156
|
+
) from rollback_error
|
|
157
|
+
raise ProviderKeyError(
|
|
158
|
+
"Legacy .env cleanup failed, so the credential-vault update was "
|
|
159
|
+
"rolled back. Fix workspace permissions and retry."
|
|
160
|
+
) from migration_error
|
|
161
|
+
if os.environ.get(spec.env_var) == legacy_value:
|
|
162
|
+
os.environ.pop(spec.env_var, None)
|
|
163
|
+
return spec.env_var
|
|
164
|
+
|
|
165
|
+
def rotate(self, provider: str, value: str) -> str:
|
|
166
|
+
"""Atomically replace a managed provider secret in the credential vault."""
|
|
167
|
+
return self.set(provider, value)
|
|
168
|
+
|
|
169
|
+
def remove(self, provider: str) -> tuple[str, bool, bool]:
|
|
170
|
+
spec = self._spec(provider)
|
|
171
|
+
values = self._workspace_values()
|
|
172
|
+
removed_from_env = spec.env_var in values
|
|
173
|
+
if removed_from_env:
|
|
174
|
+
self._rewrite(spec.env_var, None)
|
|
175
|
+
removed_from_vault = self._vault_delete(spec.key)
|
|
176
|
+
environment_still_set = self._usable(os.environ.get(spec.env_var, ""))
|
|
177
|
+
return spec.env_var, removed_from_env or removed_from_vault, environment_still_set
|
|
178
|
+
|
|
179
|
+
def _account(self, provider: str) -> str:
|
|
180
|
+
return f"{self._account_prefix}:{provider}"
|
|
181
|
+
|
|
182
|
+
def _vault_get(self, provider: str) -> str | None:
|
|
183
|
+
if keyring is None:
|
|
184
|
+
return None
|
|
185
|
+
try:
|
|
186
|
+
value = keyring.get_password(
|
|
187
|
+
KEYRING_SERVICE_NAME,
|
|
188
|
+
self._account(provider),
|
|
189
|
+
)
|
|
190
|
+
except Exception: # noqa: BLE001 - unavailable/locked vault means no key
|
|
191
|
+
return None
|
|
192
|
+
return value.strip() if self._usable(value or "") else None
|
|
193
|
+
|
|
194
|
+
def _vault_delete(self, provider: str) -> bool:
|
|
195
|
+
if keyring is None or self._vault_get(provider) is None:
|
|
196
|
+
return False
|
|
197
|
+
try:
|
|
198
|
+
keyring.delete_password(
|
|
199
|
+
KEYRING_SERVICE_NAME,
|
|
200
|
+
self._account(provider),
|
|
201
|
+
)
|
|
202
|
+
except Exception as error:
|
|
203
|
+
raise ProviderKeyError(
|
|
204
|
+
"The OS credential vault could not remove the provider key."
|
|
205
|
+
) from error
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
def _workspace_values(self) -> dict[str, str]:
|
|
209
|
+
values: dict[str, str] = {}
|
|
210
|
+
for line in self._read_lines():
|
|
211
|
+
stripped = line.strip()
|
|
212
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
213
|
+
continue
|
|
214
|
+
key, value = stripped.split("=", 1)
|
|
215
|
+
values[key.strip()] = value.strip().strip("\"'")
|
|
216
|
+
return values
|
|
217
|
+
|
|
218
|
+
def _read_lines(self) -> list[str]:
|
|
219
|
+
if not self.env_path.exists():
|
|
220
|
+
return []
|
|
221
|
+
if self.env_path.is_symlink():
|
|
222
|
+
raise ProviderKeyError("Refusing to manage a symbolic-link .env file.")
|
|
223
|
+
try:
|
|
224
|
+
self.env_path.resolve().relative_to(self.workspace)
|
|
225
|
+
except ValueError as error:
|
|
226
|
+
raise ProviderKeyError("The workspace .env file escapes the project root.") from error
|
|
227
|
+
return self.env_path.read_text(encoding="utf-8").splitlines()
|
|
228
|
+
|
|
229
|
+
def _rewrite(self, variable: str, value: str | None) -> None:
|
|
230
|
+
lines = self._read_lines()
|
|
231
|
+
updated: list[str] = []
|
|
232
|
+
replaced = False
|
|
233
|
+
for line in lines:
|
|
234
|
+
stripped = line.strip()
|
|
235
|
+
if stripped and not stripped.startswith("#") and "=" in stripped:
|
|
236
|
+
key = stripped.split("=", 1)[0].strip()
|
|
237
|
+
if key == variable:
|
|
238
|
+
if value is not None and not replaced:
|
|
239
|
+
updated.append(f"{variable}={value}")
|
|
240
|
+
replaced = True
|
|
241
|
+
continue
|
|
242
|
+
updated.append(line)
|
|
243
|
+
if value is not None and not replaced:
|
|
244
|
+
if updated and updated[-1]:
|
|
245
|
+
updated.append("")
|
|
246
|
+
updated.append(f"{variable}={value}")
|
|
247
|
+
|
|
248
|
+
self.workspace.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
temporary_name: str | None = None
|
|
250
|
+
try:
|
|
251
|
+
with tempfile.NamedTemporaryFile(
|
|
252
|
+
mode="w",
|
|
253
|
+
encoding="utf-8",
|
|
254
|
+
newline="\n",
|
|
255
|
+
prefix=".pulse-env-",
|
|
256
|
+
dir=self.workspace,
|
|
257
|
+
delete=False,
|
|
258
|
+
) as temporary:
|
|
259
|
+
temporary_name = temporary.name
|
|
260
|
+
temporary.write("\n".join(updated) + ("\n" if updated else ""))
|
|
261
|
+
os.chmod(temporary_name, 0o600)
|
|
262
|
+
os.replace(temporary_name, self.env_path)
|
|
263
|
+
temporary_name = None
|
|
264
|
+
except OSError as error:
|
|
265
|
+
raise ProviderKeyError(f"Unable to update {self.env_path}: {error}") from error
|
|
266
|
+
finally:
|
|
267
|
+
if temporary_name:
|
|
268
|
+
try:
|
|
269
|
+
Path(temporary_name).unlink()
|
|
270
|
+
except OSError:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _usable(value: str | None) -> bool:
|
|
275
|
+
if value is None:
|
|
276
|
+
return False
|
|
277
|
+
normalized = value.strip()
|
|
278
|
+
return bool(normalized and normalized.lower() not in {"replace_me", "placeholder"})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pulse.providers.anthropic import AnthropicProvider
|
|
4
|
+
from pulse.providers.base import BaseProvider, ChatMessage
|
|
5
|
+
from pulse.providers.deepseek import DeepSeekProvider
|
|
6
|
+
from pulse.providers.failover import FailoverProvider
|
|
7
|
+
from pulse.providers.gemini import GeminiProvider
|
|
8
|
+
from pulse.providers.groq import GroqProvider
|
|
9
|
+
from pulse.providers.manager import PROVIDER_SPECS, ProviderManager, ProviderSpec
|
|
10
|
+
from pulse.providers.openai import OpenAIProvider
|
|
11
|
+
from pulse.providers.openrouter import OpenRouterProvider
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"PROVIDER_SPECS",
|
|
15
|
+
"AnthropicProvider",
|
|
16
|
+
"BaseProvider",
|
|
17
|
+
"ChatMessage",
|
|
18
|
+
"DeepSeekProvider",
|
|
19
|
+
"FailoverProvider",
|
|
20
|
+
"GeminiProvider",
|
|
21
|
+
"GroqProvider",
|
|
22
|
+
"OpenAIProvider",
|
|
23
|
+
"OpenRouterProvider",
|
|
24
|
+
"ProviderManager",
|
|
25
|
+
"ProviderSpec",
|
|
26
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pulse.core.protocols import StreamChunk
|
|
7
|
+
from pulse.providers.base import BaseProvider, ChatMessage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AnthropicProvider(BaseProvider):
|
|
11
|
+
"""Anthropic Claude Messages API provider for Pulse."""
|
|
12
|
+
|
|
13
|
+
api_key_env_var = "ANTHROPIC_API_KEY"
|
|
14
|
+
endpoint = "https://api.anthropic.com/v1/messages"
|
|
15
|
+
|
|
16
|
+
def _headers(self) -> dict[str, str]:
|
|
17
|
+
return {
|
|
18
|
+
"x-api-key": self.api_key or "",
|
|
19
|
+
"anthropic-version": "2023-06-01",
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def _build_payload(
|
|
24
|
+
self,
|
|
25
|
+
messages: list[dict[str, Any] | ChatMessage],
|
|
26
|
+
temperature: float = 0.2,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
normalized = self._normalize_messages(messages)
|
|
29
|
+
system_prompts: list[str] = []
|
|
30
|
+
anthropic_messages: list[dict[str, Any]] = []
|
|
31
|
+
|
|
32
|
+
for msg in normalized:
|
|
33
|
+
role = msg.get("role", "user")
|
|
34
|
+
content = msg.get("content", "")
|
|
35
|
+
if role == "system":
|
|
36
|
+
system_prompts.append(str(content))
|
|
37
|
+
else:
|
|
38
|
+
anthropic_role = "assistant" if role == "assistant" else "user"
|
|
39
|
+
anthropic_messages.append({"role": anthropic_role, "content": str(content)})
|
|
40
|
+
|
|
41
|
+
payload: dict[str, Any] = {
|
|
42
|
+
"model": self.config.name,
|
|
43
|
+
"max_tokens": self.config.max_tokens,
|
|
44
|
+
"messages": anthropic_messages,
|
|
45
|
+
"stream": True,
|
|
46
|
+
}
|
|
47
|
+
if system_prompts:
|
|
48
|
+
payload["system"] = "\n\n".join(system_prompts)
|
|
49
|
+
if temperature is not None:
|
|
50
|
+
payload["temperature"] = temperature
|
|
51
|
+
return payload
|
|
52
|
+
|
|
53
|
+
def _parse_stream_chunk(self, payload_line: str) -> StreamChunk:
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(payload_line)
|
|
56
|
+
except json.JSONDecodeError:
|
|
57
|
+
return StreamChunk(content="", metadata={"raw_line": payload_line})
|
|
58
|
+
|
|
59
|
+
event_type = data.get("type")
|
|
60
|
+
if event_type == "content_block_delta":
|
|
61
|
+
delta = data.get("delta", {})
|
|
62
|
+
if delta.get("type") == "text_delta":
|
|
63
|
+
return StreamChunk(content=delta.get("text", ""), metadata={"raw": data})
|
|
64
|
+
|
|
65
|
+
return StreamChunk(content="", metadata={"raw": data})
|
pulse/providers/base.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import AsyncGenerator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from pulse.config import ModelConfig, load_env_file
|
|
15
|
+
from pulse.core.protocols import LLMProvider, StreamChunk
|
|
16
|
+
|
|
17
|
+
ModelProvider = LLMProvider
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ChatMessage:
|
|
22
|
+
role: str
|
|
23
|
+
content: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BaseProvider(ABC, LLMProvider):
|
|
27
|
+
"""Base class for async-first model providers independent from the CLI."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
config: ModelConfig,
|
|
32
|
+
workspace_env_path: Path | str,
|
|
33
|
+
api_key: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
env_path = (
|
|
36
|
+
Path(workspace_env_path)
|
|
37
|
+
if isinstance(workspace_env_path, str)
|
|
38
|
+
else workspace_env_path
|
|
39
|
+
)
|
|
40
|
+
env = load_env_file(env_path) if env_path.exists() else {}
|
|
41
|
+
self.config = config
|
|
42
|
+
from pulse.provider_keys import ProviderKeyStore
|
|
43
|
+
|
|
44
|
+
self.api_key = (
|
|
45
|
+
api_key
|
|
46
|
+
or ProviderKeyStore(env_path.parent).get(config.provider)
|
|
47
|
+
or os.environ.get(self.api_key_env_var)
|
|
48
|
+
or env.get(self.api_key_env_var)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_configured(self) -> bool:
|
|
53
|
+
return bool(self.api_key and self.api_key.strip() and self.api_key != "replace_me")
|
|
54
|
+
|
|
55
|
+
async def generate_stream(
|
|
56
|
+
self,
|
|
57
|
+
messages: list[dict[str, Any] | ChatMessage],
|
|
58
|
+
temperature: float = 0.2,
|
|
59
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
60
|
+
if not self.is_configured:
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"{self.api_key_env_var} is not configured. Run 'pulse keys' to add it securely."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
payload = self._build_payload(messages, temperature=temperature)
|
|
66
|
+
endpoint = self.endpoint
|
|
67
|
+
|
|
68
|
+
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
|
|
69
|
+
try:
|
|
70
|
+
async with client.stream(
|
|
71
|
+
"POST", endpoint, json=payload, headers=self._headers()
|
|
72
|
+
) as response:
|
|
73
|
+
if getattr(response, "is_error", False):
|
|
74
|
+
await response.aread()
|
|
75
|
+
if hasattr(response, "raise_for_status"):
|
|
76
|
+
response.raise_for_status()
|
|
77
|
+
|
|
78
|
+
async for line in response.aiter_lines():
|
|
79
|
+
chunk = self._process_stream_line(line)
|
|
80
|
+
if chunk is not None:
|
|
81
|
+
yield chunk
|
|
82
|
+
except httpx.TimeoutException as error:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
f"The model request to {self.config.provider} timed out."
|
|
85
|
+
) from error
|
|
86
|
+
except httpx.HTTPStatusError as error:
|
|
87
|
+
detail = self._safe_error_detail(error)
|
|
88
|
+
code = error.response.status_code if error.response else "unknown"
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
f"Model request failed ({self.config.provider} HTTP {code}): {detail}"
|
|
91
|
+
) from error
|
|
92
|
+
except httpx.HTTPError as error:
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
f"Model request to {self.config.provider} failed due to a network error."
|
|
95
|
+
) from error
|
|
96
|
+
except json.JSONDecodeError as error:
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
f"Received an invalid streaming response from {self.config.provider}."
|
|
99
|
+
) from error
|
|
100
|
+
|
|
101
|
+
def chat(
|
|
102
|
+
self,
|
|
103
|
+
messages: list[dict[str, Any] | ChatMessage],
|
|
104
|
+
temperature: float = 0.2,
|
|
105
|
+
) -> str:
|
|
106
|
+
return asyncio.run(self._chat(messages, temperature=temperature))
|
|
107
|
+
|
|
108
|
+
def stream_chat(
|
|
109
|
+
self,
|
|
110
|
+
messages: list[dict[str, Any] | ChatMessage],
|
|
111
|
+
temperature: float = 0.2,
|
|
112
|
+
) -> list[str]:
|
|
113
|
+
if not self.is_configured:
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
f"{self.api_key_env_var} is not configured. Run 'pulse keys' to add it securely."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
payload = self._build_payload(messages, temperature=temperature)
|
|
119
|
+
try:
|
|
120
|
+
with httpx.stream(
|
|
121
|
+
"POST",
|
|
122
|
+
self.endpoint,
|
|
123
|
+
json=payload,
|
|
124
|
+
headers=self._headers(),
|
|
125
|
+
timeout=self.timeout_seconds,
|
|
126
|
+
) as response:
|
|
127
|
+
if getattr(response, "is_error", False):
|
|
128
|
+
response.read()
|
|
129
|
+
if hasattr(response, "raise_for_status"):
|
|
130
|
+
response.raise_for_status()
|
|
131
|
+
|
|
132
|
+
results: list[str] = []
|
|
133
|
+
for line in response.iter_lines():
|
|
134
|
+
chunk = self._process_stream_line(line)
|
|
135
|
+
if chunk is not None and chunk.content:
|
|
136
|
+
results.append(chunk.content)
|
|
137
|
+
return results
|
|
138
|
+
except httpx.TimeoutException as error:
|
|
139
|
+
raise RuntimeError(
|
|
140
|
+
f"The model request to {self.config.provider} timed out."
|
|
141
|
+
) from error
|
|
142
|
+
except httpx.HTTPStatusError as error:
|
|
143
|
+
detail = self._safe_error_detail(error)
|
|
144
|
+
code = error.response.status_code if error.response else "unknown"
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
f"Model request failed ({self.config.provider} HTTP {code}): {detail}"
|
|
147
|
+
) from error
|
|
148
|
+
except httpx.HTTPError as error:
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
f"Model request to {self.config.provider} failed due to a network error."
|
|
151
|
+
) from error
|
|
152
|
+
except json.JSONDecodeError as error:
|
|
153
|
+
raise RuntimeError(
|
|
154
|
+
f"Received an invalid streaming response from {self.config.provider}."
|
|
155
|
+
) from error
|
|
156
|
+
|
|
157
|
+
async def _chat(
|
|
158
|
+
self,
|
|
159
|
+
messages: list[dict[str, Any] | ChatMessage],
|
|
160
|
+
*,
|
|
161
|
+
temperature: float = 0.2,
|
|
162
|
+
) -> str:
|
|
163
|
+
chunks = []
|
|
164
|
+
async for chunk in self.generate_stream(messages, temperature=temperature):
|
|
165
|
+
chunks.append(chunk.content)
|
|
166
|
+
return "".join(chunks)
|
|
167
|
+
|
|
168
|
+
async def _stream_chat(
|
|
169
|
+
self,
|
|
170
|
+
messages: list[dict[str, Any] | ChatMessage],
|
|
171
|
+
*,
|
|
172
|
+
temperature: float = 0.2,
|
|
173
|
+
) -> list[str]:
|
|
174
|
+
return [
|
|
175
|
+
chunk.content
|
|
176
|
+
async for chunk in self.generate_stream(messages, temperature=temperature)
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
def _build_payload(
|
|
180
|
+
self,
|
|
181
|
+
messages: list[dict[str, Any] | ChatMessage],
|
|
182
|
+
temperature: float = 0.2,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
normalized = self._normalize_messages(messages)
|
|
185
|
+
return {
|
|
186
|
+
"model": self.config.name,
|
|
187
|
+
"temperature": temperature,
|
|
188
|
+
"max_tokens": self.config.max_tokens,
|
|
189
|
+
"messages": normalized,
|
|
190
|
+
"stream": True,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
def _process_stream_line(self, line: str) -> StreamChunk | None:
|
|
194
|
+
if not line.startswith("data:"):
|
|
195
|
+
return None
|
|
196
|
+
payload_line = line[5:].strip()
|
|
197
|
+
if not payload_line or payload_line == "[DONE]":
|
|
198
|
+
return None
|
|
199
|
+
return self._parse_stream_chunk(payload_line)
|
|
200
|
+
|
|
201
|
+
def _safe_error_detail(self, error: httpx.HTTPError) -> str:
|
|
202
|
+
response = getattr(error, "response", None)
|
|
203
|
+
if response is None:
|
|
204
|
+
return "The provider request failed."
|
|
205
|
+
status = getattr(response, "status_code", 0)
|
|
206
|
+
|
|
207
|
+
if status == 401:
|
|
208
|
+
return f"Invalid or unauthenticated API key ({self.api_key_env_var})."
|
|
209
|
+
if status == 404:
|
|
210
|
+
return f"Model '{self.config.name}' was not found or is unavailable for {self.config.provider}."
|
|
211
|
+
if status == 429:
|
|
212
|
+
return f"Rate limit exceeded for {self.config.provider}. Please wait before retrying."
|
|
213
|
+
|
|
214
|
+
# Provider-controlled bodies may reflect prompts, request payloads, or
|
|
215
|
+
# credentials. They must never cross into CLI, RPC, telemetry, or logs.
|
|
216
|
+
return "The provider rejected the request. Check provider status and configuration."
|
|
217
|
+
|
|
218
|
+
def _normalize_messages(
|
|
219
|
+
self, messages: list[dict[str, Any] | ChatMessage]
|
|
220
|
+
) -> list[dict[str, Any]]:
|
|
221
|
+
normalized: list[dict[str, Any]] = []
|
|
222
|
+
for msg in messages:
|
|
223
|
+
if isinstance(msg, ChatMessage):
|
|
224
|
+
normalized.append({"role": msg.role, "content": msg.content})
|
|
225
|
+
elif isinstance(msg, dict):
|
|
226
|
+
normalized.append(msg)
|
|
227
|
+
else:
|
|
228
|
+
raise TypeError(f"Unsupported message type: {type(msg)}")
|
|
229
|
+
return normalized
|
|
230
|
+
|
|
231
|
+
@abstractmethod
|
|
232
|
+
def _headers(self) -> dict[str, str]:
|
|
233
|
+
raise NotImplementedError
|
|
234
|
+
|
|
235
|
+
@abstractmethod
|
|
236
|
+
def _parse_stream_chunk(self, payload_line: str) -> StreamChunk:
|
|
237
|
+
raise NotImplementedError
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
@abstractmethod
|
|
241
|
+
def api_key_env_var(self) -> str:
|
|
242
|
+
raise NotImplementedError
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
@abstractmethod
|
|
246
|
+
def endpoint(self) -> str:
|
|
247
|
+
raise NotImplementedError
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def timeout_seconds(self) -> int:
|
|
251
|
+
return 60
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pulse.providers.openai import OpenAIProvider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DeepSeekProvider(OpenAIProvider):
|
|
7
|
+
"""DeepSeek provider implementation for Pulse."""
|
|
8
|
+
|
|
9
|
+
api_key_env_var = "DEEPSEEK_API_KEY"
|
|
10
|
+
endpoint = "https://api.deepseek.com/chat/completions"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pulse.core.protocols import LLMProvider, StreamChunk
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FailoverProvider(LLMProvider):
|
|
10
|
+
"""Fallback mechanism to switch to a secondary configured provider if the primary API call fails or times out."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, primary: LLMProvider, secondary: LLMProvider) -> None:
|
|
13
|
+
self.primary = primary
|
|
14
|
+
self.secondary = secondary
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def is_configured(self) -> bool:
|
|
18
|
+
return getattr(self.primary, "is_configured", True) or getattr(self.secondary, "is_configured", True)
|
|
19
|
+
|
|
20
|
+
async def generate_stream(
|
|
21
|
+
self,
|
|
22
|
+
messages: list[dict[str, Any]],
|
|
23
|
+
temperature: float = 0.2,
|
|
24
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
25
|
+
try:
|
|
26
|
+
async for chunk in self.primary.generate_stream(messages, temperature=temperature):
|
|
27
|
+
yield chunk
|
|
28
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
29
|
+
except Exception: # noqa: BLE001
|
|
30
|
+
# Fallback to secondary provider if primary fails or times out
|
|
31
|
+
async for chunk in self.secondary.generate_stream(messages, temperature=temperature):
|
|
32
|
+
yield chunk
|