praxo-core 0.2.1__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.
praxo/__init__.py ADDED
@@ -0,0 +1,104 @@
1
+ from .agent import Agent, AgentOptions, Result, Session
2
+ from .checkpoint import Allowlist, Checkpoint, Message as CheckpointMessage
3
+ from .context import (
4
+ ContextBudget,
5
+ ContextCompactor,
6
+ ModelSummarizer,
7
+ OffloadFunc,
8
+ Offloader,
9
+ estimate_tokens,
10
+ load_agents_md,
11
+ )
12
+ from .event import Event, FilterSink, MultiSink, Sink
13
+ from .middleware import (
14
+ Middleware,
15
+ ModelCall,
16
+ ModelMiddleware,
17
+ ModelToolSelector,
18
+ PIIRedactor,
19
+ RunState,
20
+ StructuredOutput,
21
+ ToolResult,
22
+ call_limit_middleware,
23
+ default_pii_rules,
24
+ fallback_model,
25
+ pii_middleware,
26
+ tool_selection_middleware,
27
+ )
28
+ from .model import (
29
+ CacheHint,
30
+ ChatModel,
31
+ ContentBlock,
32
+ Message,
33
+ Model,
34
+ Request,
35
+ Response,
36
+ ToolCall,
37
+ ToolSpec,
38
+ Usage,
39
+ )
40
+ from .policy import Action, Decision, FuncHook, Hook, PathChecker, PathGrant
41
+ from .tool import FuncTool, Gateway, GatewayInfo, GatewayTool, Registry, Tool
42
+ from .toolcall import parse_tool_call, strip_tool_call_markup
43
+
44
+ __version__ = "0.2.1"
45
+ SPEC_VERSION = "0.2.0"
46
+
47
+ __all__ = [
48
+ "Action",
49
+ "Agent",
50
+ "AgentOptions",
51
+ "Allowlist",
52
+ "CacheHint",
53
+ "ChatModel",
54
+ "Checkpoint",
55
+ "CheckpointMessage",
56
+ "ContentBlock",
57
+ "ContextBudget",
58
+ "ContextCompactor",
59
+ "Decision",
60
+ "Event",
61
+ "FilterSink",
62
+ "FuncHook",
63
+ "FuncTool",
64
+ "Gateway",
65
+ "GatewayInfo",
66
+ "GatewayTool",
67
+ "Hook",
68
+ "Message",
69
+ "Middleware",
70
+ "Model",
71
+ "ModelCall",
72
+ "ModelMiddleware",
73
+ "ModelSummarizer",
74
+ "ModelToolSelector",
75
+ "MultiSink",
76
+ "OffloadFunc",
77
+ "Offloader",
78
+ "PIIRedactor",
79
+ "PathChecker",
80
+ "PathGrant",
81
+ "Registry",
82
+ "Request",
83
+ "Response",
84
+ "Result",
85
+ "RunState",
86
+ "SPEC_VERSION",
87
+ "Session",
88
+ "Sink",
89
+ "StructuredOutput",
90
+ "Tool",
91
+ "ToolCall",
92
+ "ToolResult",
93
+ "ToolSpec",
94
+ "Usage",
95
+ "call_limit_middleware",
96
+ "default_pii_rules",
97
+ "estimate_tokens",
98
+ "fallback_model",
99
+ "load_agents_md",
100
+ "parse_tool_call",
101
+ "pii_middleware",
102
+ "strip_tool_call_markup",
103
+ "tool_selection_middleware",
104
+ ]
@@ -0,0 +1,3 @@
1
+ from .openaicompat import OpenAIConfig, OpenAICompatibleModel
2
+
3
+ __all__ = ["OpenAIConfig", "OpenAICompatibleModel"]
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Callable
7
+ from urllib.error import HTTPError, URLError
8
+ from urllib.request import Request as HTTPRequest
9
+ from urllib.request import urlopen
10
+
11
+ from ..errors import PraxoError
12
+ from ..model import (
13
+ ROLE_ASSISTANT,
14
+ Message,
15
+ Request,
16
+ Response,
17
+ ToolCall,
18
+ Usage,
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class OpenAIConfig:
24
+ base_url: str
25
+ api_key: str = ""
26
+ model: str = ""
27
+ timeout: float = 60.0
28
+ headers: dict[str, str] = field(default_factory=dict)
29
+
30
+
31
+ class OpenAICompatibleModel:
32
+ def __init__(self, config: OpenAIConfig) -> None:
33
+ self.config = config
34
+
35
+ async def generate(self, request: Request) -> Response:
36
+ payload = self._payload(request, stream=False)
37
+ data = await asyncio.to_thread(self._post, payload)
38
+ return self._parse_response(data)
39
+
40
+ async def generate_stream(
41
+ self, request: Request, on_delta: Callable[[str], Any]
42
+ ) -> Response:
43
+ payload = self._payload(request, stream=True)
44
+ return await asyncio.to_thread(self._post_stream, payload, on_delta)
45
+
46
+ def _payload(self, request: Request, stream: bool) -> dict[str, Any]:
47
+ messages: list[dict[str, Any]] = []
48
+ for message in request.messages:
49
+ wire = message.to_wire()
50
+ if "content" not in wire and not wire.get("toolCalls"):
51
+ wire["content"] = ""
52
+ messages.append(wire)
53
+ payload: dict[str, Any] = {
54
+ "model": request.model or self.config.model,
55
+ "messages": messages,
56
+ "stream": stream,
57
+ }
58
+ if request.tools:
59
+ tools = []
60
+ for spec in request.tools:
61
+ function: dict[str, Any] = {
62
+ "name": spec.name,
63
+ "description": spec.description,
64
+ }
65
+ if spec.parameters is not None:
66
+ function["parameters"] = spec.parameters
67
+ tools.append({"type": "function", "function": function})
68
+ payload["tools"] = tools
69
+ if request.tool_choice is not None:
70
+ payload["tool_choice"] = request.tool_choice
71
+ if request.response_format is not None:
72
+ payload["response_format"] = {
73
+ "type": "json_schema",
74
+ "json_schema": {
75
+ "name": request.response_format.name or "output",
76
+ "schema": request.response_format.schema or {},
77
+ },
78
+ }
79
+ if request.temperature:
80
+ payload["temperature"] = request.temperature
81
+ return payload
82
+
83
+ def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
84
+ data = json.dumps(payload).encode("utf-8")
85
+ request = HTTPRequest(
86
+ self._endpoint(),
87
+ data=data,
88
+ headers=self._headers(),
89
+ method="POST",
90
+ )
91
+ try:
92
+ with urlopen(request, timeout=self.config.timeout) as response:
93
+ return json.loads(response.read().decode("utf-8"))
94
+ except HTTPError as exc:
95
+ body = exc.read().decode("utf-8", errors="replace")
96
+ raise PraxoError(
97
+ f"openaicompat: HTTP {exc.code}: {body[:512]}"
98
+ ) from exc
99
+ except URLError as exc:
100
+ raise PraxoError(f"openaicompat: {exc.reason}") from exc
101
+
102
+ def _post_stream(
103
+ self,
104
+ payload: dict[str, Any],
105
+ on_delta: Callable[[str], Any],
106
+ ) -> Response:
107
+ data = json.dumps(payload).encode("utf-8")
108
+ request = HTTPRequest(
109
+ self._endpoint(),
110
+ data=data,
111
+ headers=self._headers(),
112
+ method="POST",
113
+ )
114
+ content: list[str] = []
115
+ calls: dict[int, dict[str, Any]] = {}
116
+ usage = Usage()
117
+ finish_reason: str | None = None
118
+ try:
119
+ with urlopen(request, timeout=self.config.timeout) as response:
120
+ for raw_line in response:
121
+ line = raw_line.decode("utf-8").strip()
122
+ if not line.startswith("data:"):
123
+ continue
124
+ chunk = line[5:].strip()
125
+ if chunk == "[DONE]":
126
+ break
127
+ if not chunk:
128
+ continue
129
+ event = json.loads(chunk)
130
+ if event.get("usage"):
131
+ usage = _parse_usage(event["usage"])
132
+ for choice in event.get("choices", []):
133
+ finish_reason = choice.get("finish_reason") or finish_reason
134
+ delta = choice.get("delta") or {}
135
+ text = delta.get("content")
136
+ if text:
137
+ content.append(text)
138
+ on_delta(text)
139
+ for call in delta.get("tool_calls") or []:
140
+ index = call.get("index", 0)
141
+ slot = calls.setdefault(
142
+ index,
143
+ {"id": "", "name": "", "arguments": ""},
144
+ )
145
+ if call.get("id"):
146
+ slot["id"] = call["id"]
147
+ function = call.get("function") or {}
148
+ if function.get("name"):
149
+ slot["name"] += function["name"]
150
+ if function.get("arguments"):
151
+ slot["arguments"] += function["arguments"]
152
+ except HTTPError as exc:
153
+ body = exc.read().decode("utf-8", errors="replace")
154
+ raise PraxoError(
155
+ f"openaicompat: HTTP {exc.code}: {body[:512]}"
156
+ ) from exc
157
+ except URLError as exc:
158
+ raise PraxoError(f"openaicompat: {exc.reason}") from exc
159
+ tool_calls = [_build_call(index, value) for index, value in sorted(calls.items())]
160
+ return Response(
161
+ message=Message(
162
+ role=ROLE_ASSISTANT,
163
+ content="".join(content),
164
+ tool_calls=tool_calls,
165
+ ),
166
+ usage=usage,
167
+ finish_reason=finish_reason,
168
+ )
169
+
170
+ def _endpoint(self) -> str:
171
+ return self.config.base_url.rstrip("/") + "/chat/completions"
172
+
173
+ def _headers(self) -> dict[str, str]:
174
+ headers = {
175
+ "Content-Type": "application/json",
176
+ "Accept": "application/json",
177
+ }
178
+ if self.config.api_key:
179
+ headers["Authorization"] = f"Bearer {self.config.api_key}"
180
+ headers.update(self.config.headers)
181
+ return headers
182
+
183
+ def _parse_response(self, data: dict[str, Any]) -> Response:
184
+ choices = data.get("choices") or []
185
+ if not choices:
186
+ raise PraxoError("openaicompat: response has no choices")
187
+ choice = choices[0]
188
+ message = choice.get("message") or {}
189
+ calls = [
190
+ _build_call(index, call)
191
+ for index, call in enumerate(message.get("tool_calls") or [])
192
+ ]
193
+ return Response(
194
+ message=Message(
195
+ role=ROLE_ASSISTANT,
196
+ content=message.get("content") or "",
197
+ tool_calls=calls,
198
+ ),
199
+ usage=_parse_usage(data.get("usage") or {}),
200
+ finish_reason=choice.get("finish_reason"),
201
+ )
202
+
203
+
204
+ def _build_call(index: int, raw: dict[str, Any]) -> ToolCall:
205
+ function = raw.get("function")
206
+ if function is None:
207
+ function = {
208
+ "name": raw.get("name", ""),
209
+ "arguments": raw.get("arguments", "{}"),
210
+ }
211
+ arguments = function.get("arguments") or "{}"
212
+ try:
213
+ parsed = json.loads(arguments)
214
+ except (ValueError, TypeError):
215
+ parsed = {"content": arguments}
216
+ if not isinstance(parsed, dict):
217
+ parsed = {"content": parsed}
218
+ return ToolCall(
219
+ id=raw.get("id") or f"call-{index}",
220
+ name=function.get("name", ""),
221
+ arguments=parsed,
222
+ )
223
+
224
+
225
+ def _parse_usage(raw: dict[str, Any]) -> Usage:
226
+ details = raw.get("prompt_tokens_details") or {}
227
+ return Usage(
228
+ input_tokens=int(raw.get("prompt_tokens", 0)),
229
+ output_tokens=int(raw.get("completion_tokens", 0)),
230
+ cached_tokens=int(details.get("cached_tokens", 0)),
231
+ )