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.
Files changed (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. 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})
@@ -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