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.
- subactor_shell/__init__.py +3 -0
- subactor_shell/__main__.py +4 -0
- subactor_shell/acp_agent.py +363 -0
- subactor_shell/app.py +482 -0
- subactor_shell/artifacts.py +198 -0
- subactor_shell/catalog.py +416 -0
- subactor_shell/chat.py +517 -0
- subactor_shell/compiler.py +191 -0
- subactor_shell/config.py +374 -0
- subactor_shell/connectors.py +503 -0
- subactor_shell/context_builder.py +153 -0
- subactor_shell/control.py +141 -0
- subactor_shell/control_env.py +93 -0
- subactor_shell/intent_ir.py +187 -0
- subactor_shell/models.py +78 -0
- subactor_shell/operations.py +287 -0
- subactor_shell/orchestration.py +324 -0
- subactor_shell/policy.py +38 -0
- subactor_shell/providers/__init__.py +63 -0
- subactor_shell/providers/anthropic.py +101 -0
- subactor_shell/providers/base.py +81 -0
- subactor_shell/providers/mock.py +32 -0
- subactor_shell/providers/openai_compat.py +303 -0
- subactor_shell/providers/subactor_control.py +191 -0
- subactor_shell/redaction.py +62 -0
- subactor_shell/repl.py +480 -0
- subactor_shell/routing.py +334 -0
- subactor_shell/secret_refs.py +82 -0
- subactor_shell/store.py +857 -0
- subactor_shell/terminal.py +79 -0
- subactor_shell/token_budget.py +46 -0
- subactor_shell/vault.py +170 -0
- subactor_shell-0.2.2.dist-info/METADATA +449 -0
- subactor_shell-0.2.2.dist-info/RECORD +38 -0
- subactor_shell-0.2.2.dist-info/WHEEL +5 -0
- subactor_shell-0.2.2.dist-info/entry_points.txt +2 -0
- subactor_shell-0.2.2.dist-info/licenses/LICENSE +13 -0
- subactor_shell-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|