subactor-shell 0.2.2__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.
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from ..models import ProviderProfile
4
+ from ..secret_refs import SecretResolver
5
+ from .anthropic import AnthropicProvider
6
+ from .base import ChatProvider, ProviderBundle, ProviderError, StructuredCompletion
7
+ from .mock import MockProvider
8
+ from .openai_compat import OpenAICompatProvider
9
+ from .subactor_control import SubactorControlProvider
10
+
11
+
12
+ def build_provider(profile: ProviderProfile, resolver: SecretResolver) -> ProviderBundle:
13
+ kind = profile.kind.lower().strip()
14
+ if kind == "mock":
15
+ return ProviderBundle(provider=MockProvider(), sensitive_values=[])
16
+
17
+ api_key = resolver.resolve(profile.api_key_ref) if profile.api_key_ref else ""
18
+ if profile.auth_required and not api_key:
19
+ raise ProviderError(f"Provider '{profile.name}' nie ma dostępnego klucza API")
20
+
21
+ if kind == "openai_compat":
22
+ provider: ChatProvider = OpenAICompatProvider(
23
+ base_url=profile.base_url,
24
+ endpoint=profile.endpoint,
25
+ api_key=api_key,
26
+ timeout_seconds=profile.timeout_seconds,
27
+ extra_headers=profile.extra_headers,
28
+ structured_mode=profile.structured_mode,
29
+ )
30
+ elif kind == "subactor_control":
31
+ provider = SubactorControlProvider(
32
+ base_url=profile.base_url,
33
+ endpoint=profile.endpoint,
34
+ api_key=api_key,
35
+ timeout_seconds=profile.timeout_seconds,
36
+ )
37
+ elif kind == "anthropic":
38
+ if not api_key:
39
+ raise ProviderError(f"Provider '{profile.name}' wymaga klucza API")
40
+ provider = AnthropicProvider(
41
+ base_url=profile.base_url,
42
+ api_key=api_key,
43
+ max_tokens=profile.max_tokens,
44
+ anthropic_version=profile.anthropic_version,
45
+ timeout_seconds=profile.timeout_seconds,
46
+ extra_headers=profile.extra_headers,
47
+ )
48
+ else:
49
+ raise ProviderError(f"Nieobsługiwany kind providera: {profile.kind}")
50
+ return ProviderBundle(provider=provider, sensitive_values=[api_key] if api_key else [])
51
+
52
+
53
+ __all__ = [
54
+ "AnthropicProvider",
55
+ "ChatProvider",
56
+ "MockProvider",
57
+ "OpenAICompatProvider",
58
+ "SubactorControlProvider",
59
+ "ProviderBundle",
60
+ "ProviderError",
61
+ "StructuredCompletion",
62
+ "build_provider",
63
+ ]
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from .base import ChatProvider, ProviderError
10
+
11
+
12
+ class AnthropicProvider(ChatProvider):
13
+ def __init__(
14
+ self,
15
+ *,
16
+ base_url: str,
17
+ api_key: str,
18
+ max_tokens: int,
19
+ anthropic_version: str,
20
+ timeout_seconds: float,
21
+ extra_headers: dict[str, str] | None = None,
22
+ transport: httpx.AsyncBaseTransport | None = None,
23
+ ):
24
+ if not base_url.startswith(("http://", "https://")):
25
+ raise ValueError("base_url providera musi być adresem http:// lub https://")
26
+ self.base_url = base_url.rstrip("/")
27
+ self.api_key = api_key
28
+ self.max_tokens = max_tokens
29
+ self.anthropic_version = anthropic_version
30
+ self.timeout_seconds = timeout_seconds
31
+ self.extra_headers = extra_headers or {}
32
+ self.transport = transport
33
+
34
+ async def stream(
35
+ self,
36
+ messages: list[dict[str, Any]],
37
+ *,
38
+ model: str,
39
+ cancel_event=None,
40
+ ) -> AsyncIterator[str]:
41
+ system_parts: list[str] = []
42
+ api_messages: list[dict[str, str]] = []
43
+ for item in messages:
44
+ role = str(item.get("role", "user"))
45
+ content = str(item.get("content", ""))
46
+ if role == "system":
47
+ system_parts.append(content)
48
+ elif role in {"user", "assistant"}:
49
+ api_messages.append({"role": role, "content": content})
50
+ payload: dict[str, Any] = {
51
+ "model": model,
52
+ "max_tokens": self.max_tokens,
53
+ "messages": api_messages,
54
+ "stream": True,
55
+ }
56
+ if system_parts:
57
+ payload["system"] = "\n\n".join(system_parts)
58
+ headers = {
59
+ **self.extra_headers,
60
+ "x-api-key": self.api_key,
61
+ "anthropic-version": self.anthropic_version,
62
+ "Content-Type": "application/json",
63
+ "Accept": "text/event-stream",
64
+ }
65
+ try:
66
+ async with httpx.AsyncClient(
67
+ base_url=self.base_url,
68
+ timeout=self.timeout_seconds,
69
+ transport=self.transport,
70
+ ) as client:
71
+ async with client.stream("POST", "/messages", headers=headers, json=payload) as response:
72
+ if response.status_code >= 400:
73
+ request_id = response.headers.get("request-id", "")
74
+ suffix = f", request_id={request_id}" if request_id else ""
75
+ raise ProviderError(
76
+ f"Provider anthropic zwrócił HTTP {response.status_code}{suffix}"
77
+ )
78
+ async for line in response.aiter_lines():
79
+ if cancel_event is not None and cancel_event.is_set():
80
+ return
81
+ line = line.strip()
82
+ if not line or line.startswith(":") or not line.startswith("data:"):
83
+ continue
84
+ data = line[5:].strip()
85
+ try:
86
+ event = json.loads(data)
87
+ except json.JSONDecodeError:
88
+ continue
89
+ if event.get("type") == "content_block_delta":
90
+ delta = event.get("delta", {})
91
+ text = delta.get("text", "") if isinstance(delta, dict) else ""
92
+ if isinstance(text, str) and text:
93
+ yield text
94
+ elif event.get("type") == "error":
95
+ raise ProviderError("Provider anthropic przerwał strumień błędem")
96
+ except ProviderError:
97
+ raise
98
+ except httpx.HTTPError as exc:
99
+ raise ProviderError(
100
+ f"Błąd połączenia z providerem anthropic ({type(exc).__name__})"
101
+ ) from exc
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import AsyncIterator
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from ..token_budget import TokenUsage, estimate_messages_tokens, estimate_text_tokens
9
+
10
+
11
+ class ProviderError(RuntimeError):
12
+ """Sanitized provider error; never include request payloads or credentials."""
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class StructuredCompletion:
17
+ data: dict[str, Any]
18
+ raw_text: str
19
+ usage: TokenUsage = field(default_factory=TokenUsage)
20
+ request_id: str = ""
21
+
22
+
23
+ class ChatProvider(ABC):
24
+ @abstractmethod
25
+ async def stream(
26
+ self,
27
+ messages: list[dict[str, Any]],
28
+ *,
29
+ model: str,
30
+ cancel_event=None,
31
+ ) -> AsyncIterator[str]:
32
+ raise NotImplementedError
33
+
34
+ async def complete_structured(
35
+ self,
36
+ messages: list[dict[str, Any]],
37
+ *,
38
+ model: str,
39
+ json_schema: dict[str, Any],
40
+ schema_name: str,
41
+ max_output_tokens: int,
42
+ reasoning_effort: str | None = None,
43
+ ) -> StructuredCompletion:
44
+ """Portable fallback for providers without native structured output.
45
+
46
+ Native provider implementations should override this method. The fallback
47
+ still validates JSON locally; it does not trust a model's prose.
48
+ """
49
+
50
+ import json
51
+
52
+ from ..intent_ir import parse_json_object
53
+
54
+ instruction = {
55
+ "task": "Return exactly one JSON object matching the schema. No Markdown.",
56
+ "schema_name": schema_name,
57
+ "json_schema": json_schema,
58
+ }
59
+ prompted = [
60
+ {
61
+ "role": "system",
62
+ "content": json.dumps(instruction, ensure_ascii=False, separators=(",", ":")),
63
+ },
64
+ *messages,
65
+ ]
66
+ raw = "".join([chunk async for chunk in self.stream(prompted, model=model)])
67
+ return StructuredCompletion(
68
+ data=parse_json_object(raw),
69
+ raw_text=raw,
70
+ usage=TokenUsage(
71
+ input_tokens=estimate_messages_tokens(prompted),
72
+ output_tokens=estimate_text_tokens(raw),
73
+ estimated=True,
74
+ ),
75
+ )
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class ProviderBundle:
80
+ provider: ChatProvider
81
+ sensitive_values: list[str]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterator
5
+ from typing import Any
6
+
7
+ from .base import ChatProvider
8
+
9
+
10
+ class MockProvider(ChatProvider):
11
+ """Offline provider useful for installation checks and tests."""
12
+
13
+ async def stream(
14
+ self,
15
+ messages: list[dict[str, Any]],
16
+ *,
17
+ model: str,
18
+ cancel_event=None,
19
+ ) -> AsyncIterator[str]:
20
+ last_user = next(
21
+ (str(item.get("content", "")) for item in reversed(messages) if item.get("role") == "user"),
22
+ "",
23
+ )
24
+ response = (
25
+ "[mock] Odebrałem wiadomość w bezpiecznej sesji Subactor. "
26
+ f"Model: {model}. Znaki wejścia: {len(last_user)}.\n\n{last_user}"
27
+ )
28
+ for index in range(0, len(response), 24):
29
+ if cancel_event is not None and cancel_event.is_set():
30
+ return
31
+ await asyncio.sleep(0)
32
+ yield response[index : index + 24]
@@ -0,0 +1,303 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from ..intent_ir import parse_json_object
10
+ from ..token_budget import TokenUsage, estimate_messages_tokens, estimate_text_tokens
11
+ from .base import ChatProvider, ProviderError, StructuredCompletion
12
+
13
+
14
+ class OpenAICompatProvider(ChatProvider):
15
+ def __init__(
16
+ self,
17
+ *,
18
+ base_url: str,
19
+ endpoint: str,
20
+ api_key: str,
21
+ timeout_seconds: float,
22
+ extra_headers: dict[str, str] | None = None,
23
+ structured_mode: str = "auto",
24
+ transport: httpx.AsyncBaseTransport | None = None,
25
+ ):
26
+ if not base_url.startswith(("http://", "https://")):
27
+ raise ValueError("base_url providera musi być adresem http:// lub https://")
28
+ self.base_url = base_url.rstrip("/")
29
+ self.endpoint = endpoint or "/chat/completions"
30
+ if not self.endpoint.startswith("/"):
31
+ self.endpoint = "/" + self.endpoint
32
+ self.api_key = api_key
33
+ self.timeout_seconds = timeout_seconds
34
+ self.extra_headers = extra_headers or {}
35
+ self.structured_mode = structured_mode.strip().lower() or "auto"
36
+ self.transport = transport
37
+ self.last_usage = TokenUsage()
38
+
39
+ def _headers(self, *, stream: bool) -> dict[str, str]:
40
+ headers = {
41
+ **self.extra_headers,
42
+ "Content-Type": "application/json",
43
+ "Accept": "text/event-stream" if stream else "application/json",
44
+ }
45
+ if self.api_key:
46
+ headers["Authorization"] = f"Bearer {self.api_key}"
47
+ return headers
48
+
49
+ async def stream(
50
+ self,
51
+ messages: list[dict[str, Any]],
52
+ *,
53
+ model: str,
54
+ cancel_event=None,
55
+ ) -> AsyncIterator[str]:
56
+ is_responses = self.endpoint.rstrip("/").endswith("/responses")
57
+ if is_responses:
58
+ payload: dict[str, Any] = {"model": model, "input": messages, "stream": True}
59
+ else:
60
+ payload = {
61
+ "model": model,
62
+ "messages": messages,
63
+ "stream": True,
64
+ "stream_options": {"include_usage": True},
65
+ }
66
+ output_parts: list[str] = []
67
+ usage = TokenUsage(
68
+ input_tokens=estimate_messages_tokens(messages),
69
+ estimated=True,
70
+ )
71
+ try:
72
+ async with httpx.AsyncClient(
73
+ base_url=self.base_url,
74
+ timeout=self.timeout_seconds,
75
+ transport=self.transport,
76
+ ) as client:
77
+ async with client.stream(
78
+ "POST", self.endpoint, headers=self._headers(stream=True), json=payload
79
+ ) as response:
80
+ if response.status_code >= 400:
81
+ request_id = response.headers.get("x-request-id", "")
82
+ suffix = f", request_id={request_id}" if request_id else ""
83
+ raise ProviderError(
84
+ f"Provider openai_compat zwrócił HTTP {response.status_code}{suffix}"
85
+ )
86
+ async for line in response.aiter_lines():
87
+ if cancel_event is not None and cancel_event.is_set():
88
+ break
89
+ line = line.strip()
90
+ if not line or line.startswith(":") or not line.startswith("data:"):
91
+ continue
92
+ data = line[5:].strip()
93
+ if data == "[DONE]":
94
+ break
95
+ try:
96
+ event = json.loads(data)
97
+ except json.JSONDecodeError:
98
+ continue
99
+ event_usage = self._extract_usage(event, is_responses=is_responses)
100
+ if event_usage.input_tokens or event_usage.output_tokens:
101
+ usage = event_usage
102
+ text = self._extract_text(event, is_responses=is_responses)
103
+ if text:
104
+ output_parts.append(text)
105
+ yield text
106
+ except ProviderError:
107
+ raise
108
+ except httpx.HTTPError as exc:
109
+ raise ProviderError(
110
+ f"Błąd połączenia z providerem openai_compat ({type(exc).__name__})"
111
+ ) from exc
112
+ if usage.output_tokens <= 0:
113
+ usage.output_tokens = estimate_text_tokens("".join(output_parts))
114
+ usage.estimated = True
115
+ self.last_usage = usage
116
+
117
+ async def complete_structured(
118
+ self,
119
+ messages: list[dict[str, Any]],
120
+ *,
121
+ model: str,
122
+ json_schema: dict[str, Any],
123
+ schema_name: str,
124
+ max_output_tokens: int,
125
+ reasoning_effort: str | None = None,
126
+ ) -> StructuredCompletion:
127
+ is_responses = self.endpoint.rstrip("/").endswith("/responses")
128
+ mode = self.structured_mode
129
+ if mode == "auto":
130
+ mode = "responses_json_schema" if is_responses else "json_schema"
131
+
132
+ if is_responses:
133
+ payload: dict[str, Any] = {
134
+ "model": model,
135
+ "input": messages,
136
+ "stream": False,
137
+ "max_output_tokens": max_output_tokens,
138
+ }
139
+ if reasoning_effort:
140
+ payload["reasoning"] = {"effort": reasoning_effort}
141
+ if mode in {"responses_json_schema", "json_schema"}:
142
+ payload["text"] = {
143
+ "format": {
144
+ "type": "json_schema",
145
+ "name": schema_name,
146
+ "strict": True,
147
+ "schema": json_schema,
148
+ }
149
+ }
150
+ elif mode == "json_object":
151
+ payload["text"] = {"format": {"type": "json_object"}}
152
+ else:
153
+ payload["instructions"] = (
154
+ "Return exactly one JSON object matching this schema and no Markdown: "
155
+ + json.dumps(json_schema, ensure_ascii=False, separators=(",", ":"))
156
+ )
157
+ else:
158
+ payload = {
159
+ "model": model,
160
+ "messages": messages,
161
+ "stream": False,
162
+ "max_tokens": max_output_tokens,
163
+ }
164
+ if reasoning_effort:
165
+ payload["reasoning_effort"] = reasoning_effort
166
+ if mode == "json_schema":
167
+ payload["response_format"] = {
168
+ "type": "json_schema",
169
+ "json_schema": {
170
+ "name": schema_name,
171
+ "strict": True,
172
+ "schema": json_schema,
173
+ },
174
+ }
175
+ elif mode == "json_object":
176
+ payload["response_format"] = {"type": "json_object"}
177
+ else:
178
+ payload["messages"] = [
179
+ {
180
+ "role": "system",
181
+ "content": (
182
+ "Return exactly one JSON object and no Markdown. JSON Schema: "
183
+ + json.dumps(json_schema, ensure_ascii=False, separators=(",", ":"))
184
+ ),
185
+ },
186
+ *messages,
187
+ ]
188
+
189
+ try:
190
+ async with httpx.AsyncClient(
191
+ base_url=self.base_url,
192
+ timeout=self.timeout_seconds,
193
+ transport=self.transport,
194
+ ) as client:
195
+ response = await client.post(
196
+ self.endpoint,
197
+ headers=self._headers(stream=False),
198
+ json=payload,
199
+ )
200
+ if response.status_code >= 400:
201
+ request_id = response.headers.get("x-request-id", "")
202
+ suffix = f", request_id={request_id}" if request_id else ""
203
+ raise ProviderError(
204
+ f"Provider openai_compat zwrócił HTTP {response.status_code}{suffix}"
205
+ )
206
+ body = response.json()
207
+ raw_text = self._extract_complete_text(body, is_responses=is_responses)
208
+ usage = self._extract_usage(body, is_responses=is_responses)
209
+ if usage.input_tokens <= 0:
210
+ usage.input_tokens = estimate_messages_tokens(messages)
211
+ usage.estimated = True
212
+ if usage.output_tokens <= 0:
213
+ usage.output_tokens = estimate_text_tokens(raw_text)
214
+ usage.estimated = True
215
+ self.last_usage = usage
216
+ return StructuredCompletion(
217
+ data=parse_json_object(raw_text),
218
+ raw_text=raw_text,
219
+ usage=usage,
220
+ request_id=response.headers.get("x-request-id", "") or str(body.get("id", "")),
221
+ )
222
+ except ProviderError:
223
+ raise
224
+ except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
225
+ raise ProviderError(
226
+ f"Błąd structured output providera openai_compat ({type(exc).__name__})"
227
+ ) from exc
228
+
229
+ @staticmethod
230
+ def _extract_text(event: dict[str, Any], *, is_responses: bool) -> str:
231
+ if is_responses:
232
+ if event.get("type") == "response.output_text.delta":
233
+ delta = event.get("delta", "")
234
+ return delta if isinstance(delta, str) else ""
235
+ return ""
236
+ try:
237
+ content = event["choices"][0]["delta"].get("content", "")
238
+ except (KeyError, IndexError, TypeError):
239
+ return ""
240
+ return OpenAICompatProvider._content_to_text(content)
241
+
242
+ @staticmethod
243
+ def _content_to_text(content: Any) -> str:
244
+ if isinstance(content, str):
245
+ return content
246
+ if isinstance(content, list):
247
+ parts: list[str] = []
248
+ for item in content:
249
+ if isinstance(item, dict):
250
+ text = item.get("text")
251
+ if isinstance(text, str):
252
+ parts.append(text)
253
+ return "".join(parts)
254
+ return ""
255
+
256
+ @staticmethod
257
+ def _extract_complete_text(body: dict[str, Any], *, is_responses: bool) -> str:
258
+ if is_responses:
259
+ output_text = body.get("output_text")
260
+ if isinstance(output_text, str):
261
+ return output_text
262
+ parts: list[str] = []
263
+ outputs = body.get("output", [])
264
+ for output in outputs if isinstance(outputs, list) else []:
265
+ if not isinstance(output, dict):
266
+ continue
267
+ contents = output.get("content", [])
268
+ for content in contents if isinstance(contents, list) else []:
269
+ if not isinstance(content, dict):
270
+ continue
271
+ text = content.get("text")
272
+ if isinstance(text, str):
273
+ parts.append(text)
274
+ return "".join(parts)
275
+ choices = body.get("choices", [])
276
+ if not isinstance(choices, list) or not choices:
277
+ raise ProviderError("Provider nie zwrócił choices")
278
+ first = choices[0] if isinstance(choices[0], dict) else {}
279
+ message = first.get("message", {}) if isinstance(first, dict) else {}
280
+ if not isinstance(message, dict):
281
+ return ""
282
+ return OpenAICompatProvider._content_to_text(message.get("content", ""))
283
+
284
+ @staticmethod
285
+ def _extract_usage(body: dict[str, Any], *, is_responses: bool) -> TokenUsage:
286
+ usage = body.get("usage", {})
287
+ if not isinstance(usage, dict) or not usage:
288
+ return TokenUsage()
289
+ if is_responses:
290
+ details = usage.get("input_tokens_details", {})
291
+ cached = details.get("cached_tokens", 0) if isinstance(details, dict) else 0
292
+ return TokenUsage(
293
+ input_tokens=int(usage.get("input_tokens", 0) or 0),
294
+ cached_input_tokens=int(cached or 0),
295
+ output_tokens=int(usage.get("output_tokens", 0) or 0),
296
+ )
297
+ details = usage.get("prompt_tokens_details", {})
298
+ cached = details.get("cached_tokens", 0) if isinstance(details, dict) else 0
299
+ return TokenUsage(
300
+ input_tokens=int(usage.get("prompt_tokens", 0) or 0),
301
+ cached_input_tokens=int(cached or 0),
302
+ output_tokens=int(usage.get("completion_tokens", 0) or 0),
303
+ )