tessaract 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.
tessaract/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ from .client import Tessaract
2
+ from .providers.openai_provider import OpenAIProvider
3
+ from .tools.function import FunctionTool, InputSchema, Property
4
+ from .types.input_types import FunctionToolResult, UserMessage
5
+ from .types.request import ReasoningOptions
6
+
7
+ __all__ = [
8
+ "FunctionTool",
9
+ "FunctionToolResult",
10
+ "InputSchema",
11
+ "OpenAIProvider",
12
+ "Property",
13
+ "ReasoningOptions",
14
+ "Tessaract",
15
+ "UserMessage"
16
+ ]
@@ -0,0 +1,3 @@
1
+ from .adapter import Adapter
2
+
3
+ __all__ = ["Adapter"]
@@ -0,0 +1,55 @@
1
+ from typing import Any, Literal
2
+
3
+ from typing_extensions import Protocol
4
+
5
+ from ..providers.provider import Provider
6
+ from ..tools.function import InputSchema
7
+
8
+
9
+ class UserMessageProtocol(Protocol):
10
+ role: Literal["user"] = "user"
11
+ content: str | list[dict]
12
+
13
+ class FunctionToolResultProtocol(Protocol):
14
+ type: Literal["function_tool_result"] = "function_tool_result"
15
+ call_id: str
16
+ result: Any
17
+ is_error: bool = False
18
+
19
+ class FunctionToolSchemaProtocol(Protocol):
20
+ name: str
21
+ description: str
22
+ input_schema: InputSchema | None
23
+ strict: bool | None = None
24
+ provider_options: dict[str, Any]
25
+
26
+ class ReasoningParamsProtocol(Protocol):
27
+ effort: Literal["none", "minimal", "low", "medium", "high", "extra_high", "max"] | None = None
28
+ summary: Literal["concise", "auto", "detailed"] | None = None
29
+ mode: Literal["standard", "pro"] | None = None
30
+
31
+ class ReasoningProtocol(Protocol):
32
+ type: Literal["reasoning"] = "reasoning"
33
+ text: str | None = None
34
+
35
+ class Adapter:
36
+ def __init__(self, provider: Provider):
37
+ self._provider = provider
38
+
39
+ def map_input_message(self, item: UserMessageProtocol) -> Any:
40
+ raise NotImplementedError("not yet implemented...")
41
+
42
+ def map_tool_result(self, item: FunctionToolResultProtocol) -> Any:
43
+ raise NotImplementedError("not yet implemented...")
44
+
45
+ def map_function_schema(self, tools: list[FunctionToolSchemaProtocol]) -> list:
46
+ raise NotImplementedError("not yet implemented...")
47
+
48
+ def map_reasoning_params(self, item: ReasoningParamsProtocol) -> Any:
49
+ raise NotImplementedError("not yet implemented...")
50
+
51
+ def map_reasoning(self, item: ReasoningProtocol) -> Any:
52
+ raise NotImplementedError("not yet implemented")
53
+
54
+
55
+
@@ -0,0 +1,348 @@
1
+ import json
2
+ from collections.abc import Iterator, Sequence
3
+
4
+ from openai import Omit
5
+ from openai.types.shared_params import Reasoning as OpenAIReasoningParams
6
+
7
+ from ...providers.openai_provider import OpenAIProvider
8
+ from ...tools.function import InputSchema, Property
9
+ from ...types.output_types import (
10
+ AssistantMessage,
11
+ FunctionCallOutputItem,
12
+ ProviderOutputItem,
13
+ OutputType,
14
+ ReasoningOutputItem,
15
+ TextOutputItem,
16
+ )
17
+ from ...types.request import Request
18
+ from ...types.response import OpenAIResponse, ResponseError
19
+ from ...types.streaming.event_types import (
20
+ CustomProviderEvent,
21
+ FunctionCallArgumentDeltaEvent,
22
+ OutputItemCompletedEvent,
23
+ ReasoningStartedEvent,
24
+ ReasoningSummaryDeltaEvent,
25
+ ReasoningTextDeltaEvent,
26
+ ResponseCompletedEvent,
27
+ ResponseFailedEvent,
28
+ ResponseStartedEvent,
29
+ StreamEventUnion,
30
+ TextDeltaEvent,
31
+ ToolCallStartedEvent,
32
+ )
33
+ from ..adapter import (
34
+ Adapter,
35
+ FunctionToolResultProtocol,
36
+ FunctionToolSchemaProtocol,
37
+ ReasoningParamsProtocol,
38
+ UserMessageProtocol,
39
+ )
40
+
41
+
42
+ class OpenAIAdapter(Adapter):
43
+ def __init__(self, provider: OpenAIProvider):
44
+ super().__init__(provider)
45
+ self._client = provider.client
46
+
47
+ def map_input_message(self, item: UserMessageProtocol):
48
+ return {
49
+ "role": item.role,
50
+ "content": item.content
51
+ }
52
+
53
+ def map_reasoning_params(self, reasoning: ReasoningParamsProtocol | None) -> OpenAIReasoningParams | Omit | None:
54
+ if reasoning is None:
55
+ return Omit()
56
+
57
+ native_reasoning: OpenAIReasoningParams = {}
58
+
59
+ if reasoning.mode is not None:
60
+ native_reasoning["mode"] = reasoning.mode
61
+
62
+ if reasoning.summary is not None:
63
+ native_reasoning["summary"] = reasoning.summary
64
+
65
+ if reasoning.effort is not None:
66
+ native_reasoning["effort"] = (
67
+ "xhigh" if reasoning.effort == "extra_high" else reasoning.effort
68
+ )
69
+
70
+ return native_reasoning
71
+
72
+ def _native_property(self, prop: Property) -> dict:
73
+ native: dict[str, object] = {"type": prop.type}
74
+
75
+ if prop.description is not None:
76
+ native["description"] = prop.description
77
+
78
+ if prop.enum is not None:
79
+ native["enum"] = prop.enum
80
+
81
+ if prop.items is not None:
82
+ native["items"] = self._native_property(prop.items)
83
+
84
+ if prop.properties is not None:
85
+ native["properties"] = {
86
+ name: self._native_property(nested)
87
+ for name, nested in prop.properties.items()
88
+ }
89
+
90
+ if prop.required is not None:
91
+ native["required"] = prop.required
92
+
93
+ # Strict mode requires additionalProperties: false on every object
94
+ if "object" in prop._types():
95
+ native["additionalProperties"] = prop.additionalProperties if prop.additionalProperties is not None else False
96
+
97
+ return native
98
+
99
+ def _native_tool_parameters(self, input_schema: InputSchema):
100
+ _properties = {
101
+ prop_name: self._native_property(prop_schema)
102
+ for prop_name, prop_schema in input_schema.properties.items()
103
+ }
104
+
105
+ return {
106
+ "type": input_schema.type,
107
+ "properties": _properties,
108
+ "required": input_schema.required,
109
+ "additionalProperties": input_schema.additionalProperties if input_schema.additionalProperties is not None else False
110
+ }
111
+
112
+ def map_function_schema(self, tools: Sequence[FunctionToolSchemaProtocol]) -> list:
113
+
114
+ _native_tools_list = []
115
+ for tool in tools:
116
+ _native_tool_schema: dict[str, object] = {}
117
+
118
+ _native_tool_schema["type"] = "function"
119
+ _native_tool_schema["name"] = tool.name
120
+ _native_tool_schema["description"] = tool.description
121
+ _native_tool_schema["strict"] = tool.strict if tool.strict is not None else True
122
+
123
+ if tool.input_schema is not None:
124
+ _native_tool_schema["parameters"] = self._native_tool_parameters(tool.input_schema) if tool.input_schema is not None else None
125
+
126
+ canonical_keys = {
127
+ "type",
128
+ "name",
129
+ "description",
130
+ "strict",
131
+ "parameters"
132
+ }
133
+
134
+ extra_fields = {
135
+ key: value
136
+ for key, value in tool.provider_options.items()
137
+ if key not in canonical_keys
138
+ }
139
+ _native_tools_list.append({**extra_fields, **_native_tool_schema})
140
+ return _native_tools_list
141
+
142
+ def _is_content_parts(self, result) -> bool:
143
+ return isinstance(result, list) and all(
144
+ isinstance(part, dict) and "type" in part for part in result
145
+ )
146
+
147
+ def map_tool_result(self, item: FunctionToolResultProtocol):
148
+ # OpenAI has no error flag on function_call_output, so errors are marked in the output itself
149
+ if item.is_error:
150
+ output = json.dumps({"error": item.result}, default=str)
151
+
152
+ elif isinstance(item.result, str) or self._is_content_parts(item.result):
153
+ output = item.result
154
+
155
+ else:
156
+ output = json.dumps(item.result, default=str)
157
+
158
+ return {
159
+ "type": "function_call_output",
160
+ "call_id": item.call_id,
161
+ "output": output
162
+ }
163
+
164
+
165
+ def _normalize_output_item(self, output_item) -> OutputType:
166
+ if output_item.type == "message" and (
167
+ output_item.content is None or any(
168
+ part.type != "output_text"
169
+ or bool(getattr(part, "annotations", []))
170
+ for part in output_item.content
171
+ )
172
+ ):
173
+ return ProviderOutputItem(raw=output_item, provider_type=output_item.type)
174
+
175
+ match output_item.type:
176
+ case "message":
177
+ return AssistantMessage(
178
+ raw=output_item,
179
+ content=[
180
+ TextOutputItem(
181
+ raw=i,
182
+ text=i.text,
183
+ annotations=i.annotations
184
+ )
185
+ for i in output_item.content
186
+ ]
187
+ )
188
+
189
+ case "reasoning":
190
+ return ReasoningOutputItem(
191
+ raw=output_item,
192
+ id=output_item.id,
193
+ content="".join(part.text for part in output_item.content),
194
+ text="".join(part.text for part in output_item.summary)
195
+ )
196
+
197
+ case "function_call":
198
+ return FunctionCallOutputItem(
199
+ raw=output_item,
200
+ call_id=output_item.call_id,
201
+ name=output_item.name,
202
+ arguments=json.loads(output_item.arguments)
203
+ )
204
+
205
+ case _:
206
+ raise ValueError(f"Unsupported OpenAI output item type: {output_item.type!r}")
207
+
208
+
209
+ def _normalize_output(self, output_items) -> list[OutputType]:
210
+ return [self._normalize_output_item(item) for item in output_items]
211
+
212
+ def _normalize_stream_event(self, event):
213
+ match event.type:
214
+ case "response.created":
215
+ yield ResponseStartedEvent(
216
+ raw_event=event
217
+ )
218
+
219
+ case "response.completed":
220
+ yield ResponseCompletedEvent(
221
+ response=self._normalize_response(event.response),
222
+ raw_event=event
223
+ )
224
+
225
+ case "response.failed":
226
+ response = self._normalize_response(event.response)
227
+ error = response.error or ResponseError(message="Response failed")
228
+ yield ResponseFailedEvent(
229
+ message=error.message,
230
+ error=error,
231
+ response=response,
232
+ raw_event=event
233
+ )
234
+
235
+ case "error":
236
+ yield ResponseFailedEvent(
237
+ message=event.message,
238
+ error=ResponseError(message=event.message, code=event.code),
239
+ raw_event=event
240
+ )
241
+
242
+ case "response.output_item.added" if event.item.type == "function_call":
243
+ yield ToolCallStartedEvent(
244
+ call_id=event.item.call_id,
245
+ name=event.item.name,
246
+ output_index=event.output_index,
247
+ raw_event=event
248
+ )
249
+
250
+ case "response.output_text.delta":
251
+ yield TextDeltaEvent(
252
+ delta=event.delta,
253
+ provider="openai",
254
+ output_index=event.output_index,
255
+ content_index=event.content_index,
256
+ raw_event=event
257
+ )
258
+
259
+ case "response.output_item.done":
260
+ yield OutputItemCompletedEvent(
261
+ item=self._normalize_output_item(event.item),
262
+ raw_event=event
263
+ )
264
+
265
+ case "response.reasoning_summary_text.delta":
266
+ yield ReasoningSummaryDeltaEvent(
267
+ delta=event.delta,
268
+ output_index=event.output_index,
269
+ item_id=event.item_id,
270
+ raw_event=event
271
+ )
272
+
273
+ case "response.reasoning_text.delta":
274
+ yield ReasoningTextDeltaEvent(
275
+ delta=event.delta,
276
+ output_index=event.output_index,
277
+ item_id=event.item_id,
278
+ raw_event=event
279
+ )
280
+
281
+ case "response.function_call_arguments.delta":
282
+ yield FunctionCallArgumentDeltaEvent(
283
+ delta=event.delta,
284
+ output_index=event.output_index,
285
+ raw_event=event
286
+ )
287
+
288
+ case "response.reasoning_summary_part.added":
289
+ yield ReasoningStartedEvent(
290
+ item_id=event.item_id,
291
+ raw_event=event
292
+ )
293
+
294
+ case _:
295
+ yield CustomProviderEvent(
296
+ type=event.type,
297
+ raw_event=event
298
+ )
299
+
300
+
301
+ def _normalize_response(self, raw_response) -> OpenAIResponse:
302
+ return OpenAIResponse(
303
+ id=raw_response.id,
304
+ model=raw_response.model,
305
+ status=raw_response.status,
306
+ provider=self._provider,
307
+ output=self._normalize_output(raw_response.output),
308
+ error=ResponseError(message=raw_response.error.message, code=raw_response.error.code) if raw_response.error else None,
309
+ raw_response=raw_response
310
+ )
311
+
312
+ def _build_request_kwargs(self, request: Request):
313
+ canonical_params = {
314
+ "model": request.model,
315
+ "input": request.input,
316
+ "tools": self.map_function_schema(request.tools or []),
317
+ "reasoning": self.map_reasoning_params(request.reasoning),
318
+ }
319
+
320
+ provider_options = dict(**request.provider_options or {})
321
+
322
+ extra_body = provider_options.get("extra_body")
323
+
324
+ if extra_body is not None:
325
+ provider_options["extra_body"] = {
326
+ key: value
327
+ for key, value in extra_body.items()
328
+ if key not in canonical_params
329
+ }
330
+
331
+
332
+ return {**provider_options, **canonical_params}
333
+
334
+ def generate_sync(self, request: Request) -> OpenAIResponse:
335
+
336
+ kwargs = self._build_request_kwargs(request)
337
+
338
+ _raw_response = self._client.responses.create(**kwargs, stream=False)
339
+
340
+ return self._normalize_response(_raw_response)
341
+
342
+ def generate_stream(self, request: Request) -> Iterator[StreamEventUnion]:
343
+
344
+ kwargs = self._build_request_kwargs(request)
345
+
346
+ with self._client.responses.stream(**kwargs) as stream:
347
+ for raw_event in stream:
348
+ yield from self._normalize_stream_event(raw_event)
tessaract/client.py ADDED
@@ -0,0 +1,151 @@
1
+ from collections.abc import Iterator
2
+ from typing import TYPE_CHECKING, Any, Literal, overload
3
+
4
+ from .providers import OpenAIProvider
5
+ from .tools.function import FunctionTool
6
+ from .types.input_types import InputType, UserMessage
7
+ from .types.output_types import AssistantMessage, OutputItem
8
+ from .types.request import ReasoningOptions, Request
9
+ from .types.response import Response
10
+ from .types.streaming.event_types import StreamEventUnion
11
+
12
+ if TYPE_CHECKING:
13
+ from .adapters.openai.openai_adapter import OpenAIAdapter
14
+
15
+ class Tessaract:
16
+ def __init__(self, providers: dict[str, OpenAIProvider]):
17
+ self.providers = providers
18
+ self.adapters: dict[str, "OpenAIAdapter" ] = {} # value should be a union of OpenAIAdapter | AnthropicAdapter once implemented
19
+ self.register_adapter()
20
+
21
+ def register_adapter(self):
22
+ for prefix, provider in self.providers.items():
23
+ if isinstance(provider, OpenAIProvider):
24
+ from .adapters.openai.openai_adapter import OpenAIAdapter
25
+ adapter = OpenAIAdapter(provider)
26
+ else:
27
+ raise NotImplementedError("not yet implemented")
28
+ self.adapters[prefix] = adapter
29
+
30
+ def _normalize_model_name(self, model: str):
31
+ model_prefix, separator, model_name = model.partition("/")
32
+
33
+ if not separator or not model_prefix or not model_name:
34
+ raise ValueError(
35
+ f"model must be of the form '<provider prefix>/<model name>', got {model!r}"
36
+ )
37
+
38
+ return (model_prefix, model_name)
39
+
40
+ def _build_request_model(
41
+ self,
42
+ model: str,
43
+ provider: str,
44
+ input: str | list[str | InputType],
45
+ reasoning: ReasoningOptions | None,
46
+ tools: list[FunctionTool],
47
+ request_options: dict[str, Any],
48
+ stream: bool
49
+ ) -> Request:
50
+
51
+ all_items = []
52
+
53
+ if isinstance(input, str):
54
+ converted_item = UserMessage(content=input)
55
+ all_items.append(converted_item.raw(self.adapters[provider]))
56
+
57
+ else:
58
+ for message in input:
59
+ if isinstance(message, str):
60
+ converted_item = UserMessage(content=message)
61
+ all_items.append(converted_item.raw(self.adapters[provider]))
62
+
63
+ elif isinstance(message, InputType):
64
+ all_items.append(message.raw(self.adapters[provider]))
65
+
66
+ elif isinstance(message, AssistantMessage): # noqa: SIM114
67
+ all_items.append(message.raw)
68
+
69
+ elif isinstance(message, OutputItem):
70
+ all_items.append(message.raw)
71
+
72
+ else:
73
+ raise TypeError(f"Unsupported input type: {type(message).__name__}")
74
+
75
+ return Request(
76
+ model=model,
77
+ input=all_items,
78
+ reasoning=reasoning,
79
+ tools=tools,
80
+ provider_options=request_options,
81
+ stream=stream
82
+ )
83
+
84
+ @overload
85
+ def send(
86
+ self, model: str,
87
+ input: str | list[str | InputType],
88
+ stream: Literal[False],
89
+ reasoning: ReasoningOptions | None = None,
90
+ tools: list[FunctionTool] | None = None,
91
+ request_options: dict[str, Any] | None = None
92
+ ) -> Response: ...
93
+
94
+ @overload
95
+ def send(
96
+ self, model: str,
97
+ input: str | list[str | InputType],
98
+ stream: Literal[True],
99
+ reasoning: ReasoningOptions | None = None,
100
+ tools: list[FunctionTool] | None = None,
101
+ request_options: dict[str, Any] | None = None
102
+ ) -> Iterator[StreamEventUnion]: ...
103
+
104
+ @overload
105
+ def send(
106
+ self,
107
+ model: str,
108
+ input: str | list[str | InputType],
109
+ stream: bool,
110
+ reasoning: ReasoningOptions | None = None,
111
+ tools: list[FunctionTool] | None = None,
112
+ request_options: dict[str, Any] | None = None,
113
+ ) -> Response | Iterator[StreamEventUnion]: ...
114
+
115
+
116
+ def send(
117
+ self, model: str,
118
+ input: str | list[str | InputType],
119
+ stream: bool = False,
120
+ reasoning: ReasoningOptions | None = None,
121
+ tools: list[FunctionTool] | None = None,
122
+ request_options: dict[str, Any] | None = None
123
+
124
+ ) -> Response | Iterator[StreamEventUnion]:
125
+
126
+ provider, model = self._normalize_model_name(model=model)
127
+
128
+ if provider not in self.providers:
129
+ raise ValueError("provider prefix must match registered provider in tessaract object")
130
+
131
+ _request_provider = self.providers[provider]
132
+
133
+ _tools = tools if tools is not None else []
134
+
135
+ # if reasoning is not None:
136
+ # _reasoning = reasoning
137
+
138
+ _request_options = request_options if request_options is not None else {}
139
+
140
+ _tessaract_request = self._build_request_model(model=model, input=input, provider=provider, reasoning=reasoning, tools=_tools, request_options=_request_options, stream=stream)
141
+
142
+ if isinstance(_request_provider, OpenAIProvider):
143
+
144
+ adapter = self.adapters[provider]
145
+
146
+ if stream == True:
147
+ return adapter.generate_stream(request=_tessaract_request)
148
+
149
+ return adapter.generate_sync(request=_tessaract_request)
150
+
151
+ raise NotImplementedError("Unsupported provider")
@@ -0,0 +1,7 @@
1
+ from .openai_provider import OpenAIProvider
2
+ from .provider import Provider
3
+
4
+ __all__ = [
5
+ "OpenAIProvider",
6
+ "Provider"
7
+ ]
@@ -0,0 +1,35 @@
1
+ import os
2
+ from typing import TYPE_CHECKING, cast
3
+
4
+ from .provider import Provider
5
+
6
+ if TYPE_CHECKING:
7
+ from openai import OpenAI
8
+
9
+
10
+ class OpenAIProvider(Provider):
11
+ def __post_init__(self) -> None:
12
+ try:
13
+ from openai import OpenAI
14
+ except ModuleNotFoundError as exc:
15
+ if exc.name != "openai":
16
+ raise
17
+ raise ImportError(
18
+ "OpenAI support requires the optional dependency. "
19
+ 'Install it with: pip install "tessaract[openai]"'
20
+ ) from exc
21
+
22
+ if self.api_key is None:
23
+ self.api_key = os.environ.get("OPENAI_API_KEY")
24
+
25
+ if self.api_key is None:
26
+ raise ValueError(
27
+ "No OpenAI API key found. Pass api_key to OpenAIProvider "
28
+ "or set the OPENAI_API_KEY environment variable."
29
+ )
30
+
31
+ self._client = OpenAI(**self._client_kwargs())
32
+
33
+ @property
34
+ def client(self) -> "OpenAI":
35
+ return cast("OpenAI", self._client)
@@ -0,0 +1,36 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass
5
+ class Provider:
6
+ api_key: str | None = None
7
+ base_url: str | None = None
8
+ timeout: float | None = None
9
+ max_retries: int | None = None
10
+ default_headers: dict[str, str] | None = None
11
+ default_query: dict[str, object] | None = None
12
+ _client: object | None = None
13
+ provider_args: dict = field(default_factory=dict)
14
+
15
+ def __post_init__(self) -> None:
16
+ raise NotImplementedError("not yet implemented")
17
+
18
+ @property
19
+ def client(self):
20
+ raise NotImplementedError("not yet implemented")
21
+
22
+ def _client_kwargs(self) -> dict:
23
+ '''Keyword arguments for the SDK client: the typed fields that are set, overridden by provider_args.'''
24
+ typed_args = {
25
+ "api_key": self.api_key,
26
+ "base_url": self.base_url,
27
+ "timeout": self.timeout,
28
+ "max_retries": self.max_retries,
29
+ "default_headers": self.default_headers,
30
+ "default_query": self.default_query,
31
+ }
32
+
33
+ return {
34
+ **{key: value for key, value in typed_args.items() if value is not None},
35
+ **self.provider_args,
36
+ }
@@ -0,0 +1,7 @@
1
+ from .function import FunctionTool, InputSchema, Property
2
+
3
+ __all__ = [
4
+ "FunctionTool",
5
+ "InputSchema",
6
+ "Property"
7
+ ]