agent-framework-typesafe 1.0.0a261002__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.
- agent_framework_typesafe/__init__.py +17 -0
- agent_framework_typesafe/_chat_client.py +681 -0
- agent_framework_typesafe/_tool_calls.py +649 -0
- agent_framework_typesafe/py.typed +0 -0
- agent_framework_typesafe-1.0.0a261002.dist-info/METADATA +240 -0
- agent_framework_typesafe-1.0.0a261002.dist-info/RECORD +8 -0
- agent_framework_typesafe-1.0.0a261002.dist-info/WHEEL +4 -0
- agent_framework_typesafe-1.0.0a261002.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
|
|
5
|
+
from ._chat_client import RawTypeSafeChatClient, TypeSafeChatClient, TypeSafeChatOptions
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = importlib.metadata.version(__name__)
|
|
9
|
+
except importlib.metadata.PackageNotFoundError:
|
|
10
|
+
__version__ = "0.0.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"RawTypeSafeChatClient",
|
|
14
|
+
"TypeSafeChatClient",
|
|
15
|
+
"TypeSafeChatOptions",
|
|
16
|
+
"__version__",
|
|
17
|
+
]
|
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Awaitable, Mapping, Sequence
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Any, ClassVar, Literal, NoReturn, cast, overload
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
import httpx2
|
|
12
|
+
from agent_framework import (
|
|
13
|
+
BaseChatClient,
|
|
14
|
+
ChatAndFunctionMiddlewareTypes,
|
|
15
|
+
ChatMiddlewareLayer,
|
|
16
|
+
ChatOptions,
|
|
17
|
+
ChatResponse,
|
|
18
|
+
ChatResponseUpdate,
|
|
19
|
+
CompactionStrategy,
|
|
20
|
+
Content,
|
|
21
|
+
FunctionInvocationConfiguration,
|
|
22
|
+
FunctionInvocationLayer,
|
|
23
|
+
FunctionTool,
|
|
24
|
+
Message,
|
|
25
|
+
ResponseStream,
|
|
26
|
+
TokenizerProtocol,
|
|
27
|
+
UsageDetails,
|
|
28
|
+
validate_tool_mode,
|
|
29
|
+
)
|
|
30
|
+
from agent_framework._settings import SecretString, load_settings
|
|
31
|
+
from agent_framework._telemetry import get_user_agent
|
|
32
|
+
from agent_framework.exceptions import (
|
|
33
|
+
ChatClientException,
|
|
34
|
+
ChatClientInvalidAuthException,
|
|
35
|
+
ChatClientInvalidRequestException,
|
|
36
|
+
ChatClientInvalidResponseException,
|
|
37
|
+
)
|
|
38
|
+
from agent_framework.observability import ChatTelemetryLayer
|
|
39
|
+
from typesafe_sdk import (
|
|
40
|
+
AsyncTypeSafeClient,
|
|
41
|
+
ChoiceAnswer,
|
|
42
|
+
Questions,
|
|
43
|
+
ScoreAnswer,
|
|
44
|
+
SystemOneResponse,
|
|
45
|
+
TypeSafeAPIConnectionError,
|
|
46
|
+
TypeSafeAPIError,
|
|
47
|
+
TypeSafeAPIResponseValidationError,
|
|
48
|
+
TypeSafeAuthenticationError,
|
|
49
|
+
TypeSafeBadRequestError,
|
|
50
|
+
TypeSafeError,
|
|
51
|
+
TypeSafeNotFoundError,
|
|
52
|
+
TypeSafePermissionDeniedError,
|
|
53
|
+
TypeSafeUnprocessableEntityError,
|
|
54
|
+
)
|
|
55
|
+
from typing_extensions import Self, TypedDict, override
|
|
56
|
+
|
|
57
|
+
from ._tool_calls import compile_tool_call_plan
|
|
58
|
+
|
|
59
|
+
_TYPESAFE_DEFAULT_BASE_URL = "https://api.typesafe.ai"
|
|
60
|
+
_TYPESAFE_SYSTEM_ONE_PATH = "/v1/systemone"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _ApiKeyTransport(httpx2.AsyncBaseTransport):
|
|
64
|
+
"""Restore bearer authentication after the TypeSafe SDK prepares redacted wire headers."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, api_key: str) -> None:
|
|
67
|
+
self._api_key = api_key
|
|
68
|
+
self._transport = httpx2.AsyncHTTPTransport()
|
|
69
|
+
|
|
70
|
+
@override
|
|
71
|
+
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
|
|
72
|
+
request.headers["Authorization"] = f"Bearer {self._api_key}"
|
|
73
|
+
return await self._transport.handle_async_request(request)
|
|
74
|
+
|
|
75
|
+
@override
|
|
76
|
+
async def aclose(self) -> None:
|
|
77
|
+
await self._transport.aclose()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TypeSafeSettings(TypedDict, total=False):
|
|
81
|
+
"""TypeSafe settings resolved from explicit values, .env files, or the environment."""
|
|
82
|
+
|
|
83
|
+
api_key: SecretString | None
|
|
84
|
+
default_model: str | None
|
|
85
|
+
base_url: str | None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TypeSafeChatOptions(ChatOptions[SystemOneResponse], total=False):
|
|
89
|
+
"""TypeSafe-specific chat options.
|
|
90
|
+
|
|
91
|
+
Keys:
|
|
92
|
+
response_format: TypeSafe Questions mapping. The connector forwards it as
|
|
93
|
+
the TypeSafe questions parameter and returns SystemOneResponse. It may
|
|
94
|
+
be omitted when the client was created with default_questions.
|
|
95
|
+
model: Optional TypeSafe model override.
|
|
96
|
+
instructions: Optional Agent instructions included in the structured state.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class RawTypeSafeChatClient(BaseChatClient[TypeSafeChatOptions]):
|
|
103
|
+
"""Raw Agent Framework chat client for TypeSafe AI System One models.
|
|
104
|
+
|
|
105
|
+
The client maps text messages and Agent instructions to TypeSafe structured
|
|
106
|
+
state. The response_format option supplies the TypeSafe Questions mapping,
|
|
107
|
+
while every response is returned as SystemOneResponse. The raw client can
|
|
108
|
+
emit constrained function calls but does not execute them; TypeSafeChatClient
|
|
109
|
+
adds the function-invocation layer. Free-form generation, streaming, and
|
|
110
|
+
non-text message content are not supported.
|
|
111
|
+
|
|
112
|
+
Use TypeSafeChatClient for the standard middleware and telemetry layers.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
OTEL_PROVIDER_NAME: ClassVar[str] = "typesafe.ai"
|
|
116
|
+
_SUPPORTED_OPTIONS: ClassVar[frozenset[str]] = frozenset({
|
|
117
|
+
"allow_multiple_tool_calls",
|
|
118
|
+
"instructions",
|
|
119
|
+
"model",
|
|
120
|
+
"response_format",
|
|
121
|
+
"tool_choice",
|
|
122
|
+
"tools",
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
api_key: str | SecretString | None = None,
|
|
129
|
+
model: str | None = None,
|
|
130
|
+
base_url: str | None = None,
|
|
131
|
+
async_client: AsyncTypeSafeClient | None = None,
|
|
132
|
+
default_questions: Questions | None = None,
|
|
133
|
+
compaction_strategy: CompactionStrategy | None = None,
|
|
134
|
+
tokenizer: TokenizerProtocol | None = None,
|
|
135
|
+
additional_properties: dict[str, Any] | None = None,
|
|
136
|
+
env_file_path: str | None = None,
|
|
137
|
+
env_file_encoding: str | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Create a raw TypeSafe AI chat client.
|
|
140
|
+
|
|
141
|
+
Keyword Args:
|
|
142
|
+
api_key: TypeSafe API key. Defaults to the TYPESAFE_API_KEY environment variable.
|
|
143
|
+
model: Default TypeSafe model. The SDK defaults to jev-latest.
|
|
144
|
+
base_url: Optional TypeSafe API root. Defaults to TYPESAFE_BASE_URL
|
|
145
|
+
or https://api.typesafe.ai for connector-owned SDK clients.
|
|
146
|
+
async_client: Optional preconfigured TypeSafe SDK client. It remains caller-owned.
|
|
147
|
+
default_questions: Questions used when a request omits response_format.
|
|
148
|
+
This is useful for framework integrations that invoke a chat client
|
|
149
|
+
with a fixed task contract, such as SecureAgentConfig quarantine calls.
|
|
150
|
+
compaction_strategy: Optional compaction strategy applied before requests.
|
|
151
|
+
tokenizer: Optional tokenizer used by token-aware compaction strategies.
|
|
152
|
+
additional_properties: Additional properties stored on the client.
|
|
153
|
+
env_file_path: Path to a .env file used for settings resolution.
|
|
154
|
+
env_file_encoding: Encoding used to read the .env file.
|
|
155
|
+
"""
|
|
156
|
+
self._owns_client = async_client is None
|
|
157
|
+
self.default_questions = (
|
|
158
|
+
self._validate_questions_mapping(default_questions, setting_name="default_questions")
|
|
159
|
+
if default_questions is not None
|
|
160
|
+
else None
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if async_client is not None:
|
|
164
|
+
self.client = async_client
|
|
165
|
+
self.model = model
|
|
166
|
+
self.base_url = None
|
|
167
|
+
self._service_url = "Unknown"
|
|
168
|
+
else:
|
|
169
|
+
settings = load_settings(
|
|
170
|
+
TypeSafeSettings,
|
|
171
|
+
env_prefix="TYPESAFE_",
|
|
172
|
+
required_fields=["api_key"],
|
|
173
|
+
api_key=api_key,
|
|
174
|
+
default_model=model,
|
|
175
|
+
base_url=base_url,
|
|
176
|
+
env_file_path=env_file_path,
|
|
177
|
+
env_file_encoding=env_file_encoding,
|
|
178
|
+
)
|
|
179
|
+
self.model = settings.get("default_model")
|
|
180
|
+
self.base_url = settings.get("base_url") or _TYPESAFE_DEFAULT_BASE_URL
|
|
181
|
+
self._service_url = f"{self.base_url.rstrip('/')}{_TYPESAFE_SYSTEM_ONE_PATH}"
|
|
182
|
+
api_key_secret = cast(SecretString, settings.get("api_key"))
|
|
183
|
+
api_key_value = api_key_secret.get_secret_value()
|
|
184
|
+
self.client = AsyncTypeSafeClient(
|
|
185
|
+
api_key=api_key_value,
|
|
186
|
+
model=self.model,
|
|
187
|
+
base_url=self.base_url,
|
|
188
|
+
headers={"User-Agent": get_user_agent()},
|
|
189
|
+
transport=_ApiKeyTransport(api_key_value),
|
|
190
|
+
)
|
|
191
|
+
super().__init__(
|
|
192
|
+
compaction_strategy=compaction_strategy,
|
|
193
|
+
tokenizer=tokenizer,
|
|
194
|
+
additional_properties=additional_properties,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
async def __aenter__(self) -> Self:
|
|
198
|
+
"""Enter the client context."""
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
async def __aexit__(
|
|
202
|
+
self,
|
|
203
|
+
exc_type: type[BaseException] | None,
|
|
204
|
+
exc_value: BaseException | None,
|
|
205
|
+
traceback: TracebackType | None,
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Close connector-owned resources."""
|
|
208
|
+
await self.close()
|
|
209
|
+
|
|
210
|
+
async def close(self) -> None:
|
|
211
|
+
"""Close the internally created TypeSafe SDK client."""
|
|
212
|
+
if self._owns_client:
|
|
213
|
+
await self.client.aclose()
|
|
214
|
+
|
|
215
|
+
@override
|
|
216
|
+
def service_url(self) -> str:
|
|
217
|
+
"""Return the TypeSafe System One endpoint."""
|
|
218
|
+
return self._service_url
|
|
219
|
+
|
|
220
|
+
@override
|
|
221
|
+
def _inner_get_response(
|
|
222
|
+
self,
|
|
223
|
+
*,
|
|
224
|
+
messages: Sequence[Message],
|
|
225
|
+
stream: bool,
|
|
226
|
+
options: Mapping[str, Any],
|
|
227
|
+
**kwargs: Any,
|
|
228
|
+
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
|
|
229
|
+
"""Evaluate messages with TypeSafe and return structured answers."""
|
|
230
|
+
if stream:
|
|
231
|
+
raise ChatClientInvalidRequestException("TypeSafe System One does not support streaming responses.")
|
|
232
|
+
unexpected_kwargs = set(kwargs) - {"tool_choice"}
|
|
233
|
+
if unexpected_kwargs:
|
|
234
|
+
raise ChatClientInvalidRequestException(
|
|
235
|
+
f"TypeSafe does not support client-specific arguments: {', '.join(sorted(unexpected_kwargs))}."
|
|
236
|
+
)
|
|
237
|
+
request_options = dict(options)
|
|
238
|
+
if "tool_choice" in kwargs:
|
|
239
|
+
request_options.setdefault("tool_choice", kwargs["tool_choice"])
|
|
240
|
+
|
|
241
|
+
async def _get_response() -> ChatResponse[Any]:
|
|
242
|
+
normalized_options = await self._validate_options(request_options)
|
|
243
|
+
self._validate_supported_options(normalized_options)
|
|
244
|
+
|
|
245
|
+
model = normalized_options.get("model", self.model)
|
|
246
|
+
if model is not None and not isinstance(model, str):
|
|
247
|
+
raise ChatClientInvalidRequestException("TypeSafe model must be a string.")
|
|
248
|
+
|
|
249
|
+
user_questions = self._get_questions(normalized_options)
|
|
250
|
+
tools = self._get_function_tools(normalized_options)
|
|
251
|
+
tool_mode = validate_tool_mode(normalized_options.get("tool_choice"))
|
|
252
|
+
tool_plan = compile_tool_call_plan(
|
|
253
|
+
tools,
|
|
254
|
+
tool_mode=tool_mode,
|
|
255
|
+
user_question_ids=set(user_questions),
|
|
256
|
+
previous_calls=self._get_current_turn_function_calls(messages),
|
|
257
|
+
)
|
|
258
|
+
questions = dict(user_questions)
|
|
259
|
+
if tool_plan is not None:
|
|
260
|
+
questions.update(tool_plan.questions)
|
|
261
|
+
|
|
262
|
+
state = self._build_state(
|
|
263
|
+
messages,
|
|
264
|
+
instructions=normalized_options.get("instructions"),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
response = await self.client.system_one( # pyright: ignore[reportUnknownMemberType]
|
|
269
|
+
state=state,
|
|
270
|
+
questions=questions,
|
|
271
|
+
model=model,
|
|
272
|
+
response_model=SystemOneResponse,
|
|
273
|
+
)
|
|
274
|
+
except TypeSafeError as exc:
|
|
275
|
+
self._raise_sdk_error(exc)
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
raise ChatClientException(
|
|
278
|
+
f"TypeSafe request failed: {exc}",
|
|
279
|
+
inner_exception=exc,
|
|
280
|
+
) from exc
|
|
281
|
+
|
|
282
|
+
if not isinstance(response, SystemOneResponse):
|
|
283
|
+
raise ChatClientInvalidResponseException(
|
|
284
|
+
"TypeSafe returned a response that does not match SystemOneResponse."
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
input_tokens = response.usage.input_tokens
|
|
288
|
+
output_tokens = response.usage.output_tokens
|
|
289
|
+
usage_details = self._build_usage_details(input_tokens, output_tokens)
|
|
290
|
+
|
|
291
|
+
if tool_plan is not None and (tool_call := tool_plan.decode(response)) is not None:
|
|
292
|
+
function, arguments = tool_call
|
|
293
|
+
return ChatResponse(
|
|
294
|
+
messages=[
|
|
295
|
+
Message(
|
|
296
|
+
role="assistant",
|
|
297
|
+
contents=[
|
|
298
|
+
Content.from_function_call(
|
|
299
|
+
call_id=f"typesafe-{uuid4().hex}",
|
|
300
|
+
name=function.name,
|
|
301
|
+
arguments=arguments,
|
|
302
|
+
)
|
|
303
|
+
],
|
|
304
|
+
)
|
|
305
|
+
],
|
|
306
|
+
response_id=response.request_id,
|
|
307
|
+
model=response.model,
|
|
308
|
+
finish_reason="tool_calls",
|
|
309
|
+
usage_details=usage_details or None,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
response = self._filter_internal_answers(response, set(user_questions))
|
|
313
|
+
terminal_text = self._build_terminal_text(messages, response)
|
|
314
|
+
return ChatResponse(
|
|
315
|
+
messages=[Message(role="assistant", contents=[terminal_text])],
|
|
316
|
+
response_id=response.request_id,
|
|
317
|
+
model=response.model,
|
|
318
|
+
finish_reason="stop",
|
|
319
|
+
usage_details=usage_details or None,
|
|
320
|
+
value=response,
|
|
321
|
+
response_format=SystemOneResponse,
|
|
322
|
+
raw_representation=response,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
return _get_response()
|
|
326
|
+
|
|
327
|
+
@override
|
|
328
|
+
async def _validate_options(self, options: Mapping[str, Any]) -> dict[str, Any]:
|
|
329
|
+
raw_options = dict(options)
|
|
330
|
+
raw_tools = raw_options.pop("tools", None)
|
|
331
|
+
validated = await super()._validate_options(raw_options)
|
|
332
|
+
if raw_tools is None:
|
|
333
|
+
return validated
|
|
334
|
+
tool_items: list[Any] = (
|
|
335
|
+
list(cast(Sequence[Any], raw_tools))
|
|
336
|
+
if isinstance(raw_tools, Sequence) and not isinstance(raw_tools, (str, bytes))
|
|
337
|
+
else [raw_tools]
|
|
338
|
+
)
|
|
339
|
+
if not all(isinstance(tool, FunctionTool) for tool in tool_items):
|
|
340
|
+
raise ChatClientInvalidRequestException(
|
|
341
|
+
"TypeSafe raw tool routing requires FunctionTool instances. "
|
|
342
|
+
"Pass MCPTool objects through Agent.run so Agent Framework can connect and expand them first."
|
|
343
|
+
)
|
|
344
|
+
validated["tools"] = list(tool_items)
|
|
345
|
+
return validated
|
|
346
|
+
|
|
347
|
+
@classmethod
|
|
348
|
+
def _validate_supported_options(cls, options: Mapping[str, Any]) -> None:
|
|
349
|
+
unsupported = sorted(
|
|
350
|
+
key for key, value in options.items() if key not in cls._SUPPORTED_OPTIONS and value is not None
|
|
351
|
+
)
|
|
352
|
+
if unsupported:
|
|
353
|
+
raise ChatClientInvalidRequestException(
|
|
354
|
+
"TypeSafe does not support these chat options: "
|
|
355
|
+
f"{', '.join(unsupported)}. Use response_format to define structured judgments."
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if options.get("allow_multiple_tool_calls"):
|
|
359
|
+
raise ChatClientInvalidRequestException("TypeSafe supports one tool call per agent run.")
|
|
360
|
+
|
|
361
|
+
def _get_questions(self, options: Mapping[str, Any]) -> Questions:
|
|
362
|
+
response_format = options.get("response_format")
|
|
363
|
+
if response_format is None and self.default_questions is not None:
|
|
364
|
+
return self.default_questions
|
|
365
|
+
return self._validate_questions_mapping(response_format, setting_name="response_format")
|
|
366
|
+
|
|
367
|
+
@staticmethod
|
|
368
|
+
def _validate_questions_mapping(value: Any, *, setting_name: str) -> Questions:
|
|
369
|
+
if not isinstance(value, Mapping) or not value:
|
|
370
|
+
raise ChatClientInvalidRequestException(
|
|
371
|
+
f"TypeSafe {setting_name} must be a non-empty typesafe_sdk.Questions mapping."
|
|
372
|
+
)
|
|
373
|
+
question_ids: list[Any] = list(cast(Mapping[Any, Any], value))
|
|
374
|
+
invalid_question_ids = [question_id for question_id in question_ids if not isinstance(question_id, str)]
|
|
375
|
+
if invalid_question_ids:
|
|
376
|
+
raise ChatClientInvalidRequestException(f"TypeSafe {setting_name} question IDs must be strings.")
|
|
377
|
+
return cast(Questions, value)
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _get_function_tools(options: Mapping[str, Any]) -> list[FunctionTool]:
|
|
381
|
+
tools = options.get("tools")
|
|
382
|
+
if tools is None:
|
|
383
|
+
return []
|
|
384
|
+
if isinstance(tools, Sequence) and not isinstance(tools, (str, bytes)):
|
|
385
|
+
return [cast(FunctionTool, tool) for tool in cast(Sequence[Any], tools)]
|
|
386
|
+
return [cast(FunctionTool, tools)]
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def _get_current_turn_function_calls(messages: Sequence[Message]) -> dict[str, list[dict[str, Any]]]:
|
|
390
|
+
calls: dict[str, list[dict[str, Any]]] = {}
|
|
391
|
+
for message in reversed(messages):
|
|
392
|
+
if message.role == "user":
|
|
393
|
+
break
|
|
394
|
+
for content in reversed(message.contents):
|
|
395
|
+
if content.type == "function_call" and content.name:
|
|
396
|
+
calls.setdefault(content.name, []).append(content.parse_arguments() or {})
|
|
397
|
+
for function_calls in calls.values():
|
|
398
|
+
function_calls.reverse()
|
|
399
|
+
return calls
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def _build_state(messages: Sequence[Message], *, instructions: Any) -> dict[str, Any]:
|
|
403
|
+
if instructions is not None and not isinstance(instructions, str):
|
|
404
|
+
raise ChatClientInvalidRequestException("TypeSafe instructions must be a string.")
|
|
405
|
+
|
|
406
|
+
state_messages: list[dict[str, Any]] = []
|
|
407
|
+
for index, message in enumerate(messages):
|
|
408
|
+
unsupported_content_types = sorted({
|
|
409
|
+
content.type
|
|
410
|
+
for content in message.contents
|
|
411
|
+
if content.type not in {"text", "text_reasoning", "function_call", "function_result"}
|
|
412
|
+
})
|
|
413
|
+
if unsupported_content_types:
|
|
414
|
+
raise ChatClientInvalidRequestException(
|
|
415
|
+
f"TypeSafe only supports text, reasoning summaries, and function call/result content; "
|
|
416
|
+
f"message {index} contains: "
|
|
417
|
+
f"{', '.join(unsupported_content_types)}."
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
contents: list[dict[str, Any]] = []
|
|
421
|
+
for content in message.contents:
|
|
422
|
+
if content.type == "text" and content.text:
|
|
423
|
+
contents.append({"type": "text", "text": content.text})
|
|
424
|
+
elif content.type == "text_reasoning" and content.text:
|
|
425
|
+
contents.append({"type": "text_reasoning", "text": content.text})
|
|
426
|
+
elif content.type == "function_call":
|
|
427
|
+
contents.append({
|
|
428
|
+
"type": "function_call",
|
|
429
|
+
"call_id": content.call_id,
|
|
430
|
+
"name": content.name,
|
|
431
|
+
"arguments": content.parse_arguments(),
|
|
432
|
+
})
|
|
433
|
+
elif content.type == "function_result":
|
|
434
|
+
contents.append(RawTypeSafeChatClient._serialize_function_result(content))
|
|
435
|
+
if contents:
|
|
436
|
+
state_messages.append({
|
|
437
|
+
"role": str(message.role),
|
|
438
|
+
"contents": contents,
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
if not state_messages:
|
|
442
|
+
raise ChatClientInvalidRequestException("TypeSafe requires at least one non-empty text message.")
|
|
443
|
+
|
|
444
|
+
state: dict[str, Any] = {"messages": state_messages}
|
|
445
|
+
if instructions:
|
|
446
|
+
state["instructions"] = instructions
|
|
447
|
+
return state
|
|
448
|
+
|
|
449
|
+
@staticmethod
|
|
450
|
+
def _serialize_function_result(content: Content) -> dict[str, Any]:
|
|
451
|
+
items: list[dict[str, str]] = []
|
|
452
|
+
for item in content.items or ():
|
|
453
|
+
if item.type != "text":
|
|
454
|
+
raise ChatClientInvalidRequestException(
|
|
455
|
+
f"TypeSafe function results support text items only; received unsupported {item.type!r} content."
|
|
456
|
+
)
|
|
457
|
+
items.append({"type": "text", "text": item.text or ""})
|
|
458
|
+
return {
|
|
459
|
+
"type": "function_result",
|
|
460
|
+
"call_id": content.call_id,
|
|
461
|
+
"result": content.result,
|
|
462
|
+
**({"items": items} if items else {}),
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
@staticmethod
|
|
466
|
+
def _build_usage_details(input_tokens: int | None, output_tokens: int | None) -> UsageDetails:
|
|
467
|
+
return UsageDetails(
|
|
468
|
+
**({"input_token_count": input_tokens} if input_tokens is not None else {}),
|
|
469
|
+
**({"output_token_count": output_tokens} if output_tokens is not None else {}),
|
|
470
|
+
**(
|
|
471
|
+
{"total_token_count": input_tokens + output_tokens}
|
|
472
|
+
if input_tokens is not None and output_tokens is not None
|
|
473
|
+
else {}
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
@staticmethod
|
|
478
|
+
def _build_terminal_text(messages: Sequence[Message], response: SystemOneResponse) -> str:
|
|
479
|
+
tool_results = RawTypeSafeChatClient._get_current_turn_function_result_texts(messages)
|
|
480
|
+
if not tool_results:
|
|
481
|
+
return response.model_dump_json()
|
|
482
|
+
|
|
483
|
+
decision_lines = [
|
|
484
|
+
f"{question_id}: {answer.choice}"
|
|
485
|
+
if isinstance(answer, ChoiceAnswer)
|
|
486
|
+
else f"{question_id}: {answer.score:g}"
|
|
487
|
+
for question_id, answer in response.answers.items()
|
|
488
|
+
if isinstance(answer, (ChoiceAnswer, ScoreAnswer))
|
|
489
|
+
]
|
|
490
|
+
return "\n".join([*tool_results, *decision_lines])
|
|
491
|
+
|
|
492
|
+
@staticmethod
|
|
493
|
+
def _get_current_turn_function_result_texts(messages: Sequence[Message]) -> list[str]:
|
|
494
|
+
results: list[str] = []
|
|
495
|
+
for message in reversed(messages):
|
|
496
|
+
if message.role == "user":
|
|
497
|
+
break
|
|
498
|
+
for content in reversed(message.contents):
|
|
499
|
+
if content.type == "function_result" and isinstance(content.result, str):
|
|
500
|
+
results.append(RawTypeSafeChatClient._unwrap_function_result_text(content.result))
|
|
501
|
+
results.reverse()
|
|
502
|
+
return results
|
|
503
|
+
|
|
504
|
+
@staticmethod
|
|
505
|
+
def _unwrap_function_result_text(result: str) -> str:
|
|
506
|
+
try:
|
|
507
|
+
decoded: Any = json.loads(result)
|
|
508
|
+
except json.JSONDecodeError:
|
|
509
|
+
return result
|
|
510
|
+
if isinstance(decoded, dict):
|
|
511
|
+
decoded_result = cast(dict[str, Any], decoded)
|
|
512
|
+
if set(decoded_result) == {"result"} and isinstance(decoded_result["result"], str):
|
|
513
|
+
return decoded_result["result"]
|
|
514
|
+
return result
|
|
515
|
+
|
|
516
|
+
@staticmethod
|
|
517
|
+
def _filter_internal_answers(response: SystemOneResponse, question_ids: set[str]) -> SystemOneResponse:
|
|
518
|
+
missing = sorted(question_id for question_id in question_ids if question_id not in response.answers)
|
|
519
|
+
if missing:
|
|
520
|
+
raise ChatClientInvalidResponseException(
|
|
521
|
+
f"TypeSafe response is missing configured answers: {', '.join(missing)}."
|
|
522
|
+
)
|
|
523
|
+
if set(response.answers) == question_ids:
|
|
524
|
+
return response
|
|
525
|
+
filtered = response.model_copy(
|
|
526
|
+
update={"answers": {question_id: response.answers[question_id] for question_id in question_ids}}
|
|
527
|
+
)
|
|
528
|
+
filtered.__dict__.pop("_raw", None) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
|
|
529
|
+
return filtered
|
|
530
|
+
|
|
531
|
+
@staticmethod
|
|
532
|
+
def _raise_sdk_error(exc: TypeSafeError) -> NoReturn:
|
|
533
|
+
if isinstance(exc, TypeSafeAPIResponseValidationError):
|
|
534
|
+
raise ChatClientInvalidResponseException(
|
|
535
|
+
f"TypeSafe returned an invalid response: {exc}",
|
|
536
|
+
inner_exception=exc,
|
|
537
|
+
) from exc
|
|
538
|
+
if isinstance(exc, (TypeSafeAuthenticationError, TypeSafePermissionDeniedError)):
|
|
539
|
+
raise ChatClientInvalidAuthException(
|
|
540
|
+
f"TypeSafe authentication failed: {exc}",
|
|
541
|
+
inner_exception=exc,
|
|
542
|
+
) from exc
|
|
543
|
+
if isinstance(exc, (TypeSafeBadRequestError, TypeSafeNotFoundError, TypeSafeUnprocessableEntityError)):
|
|
544
|
+
raise ChatClientInvalidRequestException(
|
|
545
|
+
f"Invalid TypeSafe request: {exc}",
|
|
546
|
+
inner_exception=exc,
|
|
547
|
+
) from exc
|
|
548
|
+
if isinstance(exc, (TypeSafeAPIConnectionError, TypeSafeAPIError)):
|
|
549
|
+
raise ChatClientException(
|
|
550
|
+
f"TypeSafe request failed: {exc}",
|
|
551
|
+
inner_exception=exc,
|
|
552
|
+
) from exc
|
|
553
|
+
raise ChatClientInvalidRequestException(
|
|
554
|
+
f"Invalid TypeSafe request: {exc}",
|
|
555
|
+
inner_exception=exc,
|
|
556
|
+
) from exc
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
class TypeSafeChatClient(
|
|
560
|
+
FunctionInvocationLayer[TypeSafeChatOptions],
|
|
561
|
+
ChatMiddlewareLayer[TypeSafeChatOptions],
|
|
562
|
+
ChatTelemetryLayer[TypeSafeChatOptions],
|
|
563
|
+
RawTypeSafeChatClient,
|
|
564
|
+
):
|
|
565
|
+
"""TypeSafe AI chat client with function invocation, middleware, and telemetry support.
|
|
566
|
+
|
|
567
|
+
This is the recommended client for most uses. Use RawTypeSafeChatClient
|
|
568
|
+
when composing a custom layer stack or opting out of telemetry.
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
@overload
|
|
572
|
+
def get_response(
|
|
573
|
+
self,
|
|
574
|
+
messages: Sequence[Message],
|
|
575
|
+
*,
|
|
576
|
+
stream: Literal[False] = ...,
|
|
577
|
+
options: TypeSafeChatOptions | ChatOptions[Any] | None = None,
|
|
578
|
+
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
|
579
|
+
compaction_strategy: CompactionStrategy | None = None,
|
|
580
|
+
tokenizer: TokenizerProtocol | None = None,
|
|
581
|
+
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
|
582
|
+
client_kwargs: Mapping[str, Any] | None = None,
|
|
583
|
+
) -> Awaitable[ChatResponse[Any]]: ...
|
|
584
|
+
|
|
585
|
+
@overload
|
|
586
|
+
def get_response(
|
|
587
|
+
self,
|
|
588
|
+
messages: Sequence[Message],
|
|
589
|
+
*,
|
|
590
|
+
stream: Literal[True],
|
|
591
|
+
options: TypeSafeChatOptions | ChatOptions[Any] | None = None,
|
|
592
|
+
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
|
593
|
+
compaction_strategy: CompactionStrategy | None = None,
|
|
594
|
+
tokenizer: TokenizerProtocol | None = None,
|
|
595
|
+
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
|
596
|
+
client_kwargs: Mapping[str, Any] | None = None,
|
|
597
|
+
) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ...
|
|
598
|
+
|
|
599
|
+
@override
|
|
600
|
+
def get_response(
|
|
601
|
+
self,
|
|
602
|
+
messages: Sequence[Message],
|
|
603
|
+
*,
|
|
604
|
+
stream: bool = False,
|
|
605
|
+
options: TypeSafeChatOptions | ChatOptions[Any] | None = None,
|
|
606
|
+
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
|
607
|
+
compaction_strategy: CompactionStrategy | None = None,
|
|
608
|
+
tokenizer: TokenizerProtocol | None = None,
|
|
609
|
+
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
|
610
|
+
client_kwargs: Mapping[str, Any] | None = None,
|
|
611
|
+
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
|
|
612
|
+
"""Reject streaming before the function-invocation layer can execute tools."""
|
|
613
|
+
if stream:
|
|
614
|
+
|
|
615
|
+
async def _reject_streaming() -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: # ruff: ignore[unused-async]
|
|
616
|
+
raise ChatClientInvalidRequestException("TypeSafe System One does not support streaming responses.")
|
|
617
|
+
|
|
618
|
+
return ResponseStream.from_awaitable( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
|
619
|
+
_reject_streaming()
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
return super().get_response(
|
|
623
|
+
messages,
|
|
624
|
+
stream=False,
|
|
625
|
+
options=options,
|
|
626
|
+
middleware=middleware,
|
|
627
|
+
compaction_strategy=compaction_strategy,
|
|
628
|
+
tokenizer=tokenizer,
|
|
629
|
+
function_invocation_kwargs=function_invocation_kwargs,
|
|
630
|
+
client_kwargs=client_kwargs,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
def __init__(
|
|
634
|
+
self,
|
|
635
|
+
*,
|
|
636
|
+
api_key: str | SecretString | None = None,
|
|
637
|
+
model: str | None = None,
|
|
638
|
+
base_url: str | None = None,
|
|
639
|
+
async_client: AsyncTypeSafeClient | None = None,
|
|
640
|
+
default_questions: Questions | None = None,
|
|
641
|
+
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
|
642
|
+
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
|
643
|
+
compaction_strategy: CompactionStrategy | None = None,
|
|
644
|
+
tokenizer: TokenizerProtocol | None = None,
|
|
645
|
+
additional_properties: dict[str, Any] | None = None,
|
|
646
|
+
env_file_path: str | None = None,
|
|
647
|
+
env_file_encoding: str | None = None,
|
|
648
|
+
) -> None:
|
|
649
|
+
"""Create a layered TypeSafe AI chat client.
|
|
650
|
+
|
|
651
|
+
Keyword Args:
|
|
652
|
+
api_key: TypeSafe API key. Defaults to the TYPESAFE_API_KEY environment variable.
|
|
653
|
+
model: Default TypeSafe model. The SDK defaults to jev-latest.
|
|
654
|
+
base_url: Optional TypeSafe API root for connector-owned SDK clients.
|
|
655
|
+
async_client: Optional preconfigured TypeSafe SDK client. It remains caller-owned.
|
|
656
|
+
default_questions: Questions used when a request omits response_format.
|
|
657
|
+
middleware: Chat and function middleware to apply around requests and tool calls.
|
|
658
|
+
function_invocation_configuration: Function invocation settings. TypeSafe limits
|
|
659
|
+
each run to one executed tool call.
|
|
660
|
+
compaction_strategy: Optional compaction strategy applied before requests.
|
|
661
|
+
tokenizer: Optional tokenizer used by token-aware compaction strategies.
|
|
662
|
+
additional_properties: Additional properties stored on the client.
|
|
663
|
+
env_file_path: Path to a .env file used for settings resolution.
|
|
664
|
+
env_file_encoding: Encoding used to read the .env file.
|
|
665
|
+
"""
|
|
666
|
+
invocation_configuration = dict(function_invocation_configuration or {})
|
|
667
|
+
invocation_configuration.setdefault("max_function_calls", 1)
|
|
668
|
+
super().__init__(
|
|
669
|
+
api_key=api_key,
|
|
670
|
+
model=model,
|
|
671
|
+
base_url=base_url,
|
|
672
|
+
async_client=async_client,
|
|
673
|
+
default_questions=default_questions,
|
|
674
|
+
middleware=middleware,
|
|
675
|
+
function_invocation_configuration=cast(FunctionInvocationConfiguration, invocation_configuration),
|
|
676
|
+
compaction_strategy=compaction_strategy,
|
|
677
|
+
tokenizer=tokenizer,
|
|
678
|
+
additional_properties=additional_properties,
|
|
679
|
+
env_file_path=env_file_path,
|
|
680
|
+
env_file_encoding=env_file_encoding,
|
|
681
|
+
)
|