latitude-sdk 3.0.1__py3-none-any.whl → 4.0.0b1__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.
- latitude_sdk/client/client.py +187 -51
- latitude_sdk/client/payloads.py +16 -0
- latitude_sdk/client/router.py +10 -1
- latitude_sdk/sdk/latitude.py +3 -0
- latitude_sdk/sdk/projects.py +41 -0
- latitude_sdk/sdk/prompts.py +86 -113
- latitude_sdk/sdk/types.py +17 -15
- {latitude_sdk-3.0.1.dist-info → latitude_sdk-4.0.0b1.dist-info}/METADATA +10 -7
- latitude_sdk-4.0.0b1.dist-info/RECORD +22 -0
- latitude_sdk-4.0.0b1.dist-info/licenses/LICENSE.md +21 -0
- latitude_sdk-3.0.1.dist-info/RECORD +0 -20
- {latitude_sdk-3.0.1.dist-info → latitude_sdk-4.0.0b1.dist-info}/WHEEL +0 -0
latitude_sdk/client/client.py
CHANGED
@@ -1,12 +1,17 @@
|
|
1
1
|
import asyncio
|
2
2
|
import json
|
3
3
|
from contextlib import asynccontextmanager
|
4
|
-
from typing import Any, AsyncGenerator, Optional
|
4
|
+
from typing import Any, AsyncGenerator, Awaitable, Callable, Optional
|
5
5
|
|
6
6
|
import httpx
|
7
7
|
import httpx_sse
|
8
8
|
|
9
|
-
from latitude_sdk.client.payloads import
|
9
|
+
from latitude_sdk.client.payloads import (
|
10
|
+
ErrorResponse,
|
11
|
+
RequestBody,
|
12
|
+
RequestHandler,
|
13
|
+
RequestParams,
|
14
|
+
)
|
10
15
|
from latitude_sdk.client.router import Router, RouterOptions
|
11
16
|
from latitude_sdk.sdk.errors import (
|
12
17
|
ApiError,
|
@@ -51,65 +56,169 @@ class Client:
|
|
51
56
|
|
52
57
|
@asynccontextmanager
|
53
58
|
async def request(
|
54
|
-
self,
|
59
|
+
self,
|
60
|
+
handler: RequestHandler,
|
61
|
+
params: Optional[RequestParams] = None,
|
62
|
+
body: Optional[RequestBody] = None,
|
63
|
+
) -> AsyncGenerator[ClientResponse, Any]:
|
64
|
+
"""
|
65
|
+
Main request handler that delegates to streaming or non-streaming methods.
|
66
|
+
"""
|
67
|
+
is_streaming = self._is_streaming_request(body)
|
68
|
+
|
69
|
+
if is_streaming:
|
70
|
+
async with self._streaming_request(handler, params, body) as response:
|
71
|
+
yield response
|
72
|
+
else:
|
73
|
+
async with self._non_streaming_request(handler, params, body) as response:
|
74
|
+
yield response
|
75
|
+
|
76
|
+
def _is_streaming_request(self, body: Optional[RequestBody]) -> bool:
|
77
|
+
"""Check if this is a streaming request based on the body."""
|
78
|
+
return body is not None and hasattr(body, "stream") and body.stream # type: ignore
|
79
|
+
|
80
|
+
def _prepare_headers(self, is_streaming: bool) -> dict[str, str]:
|
81
|
+
"""Prepare headers for the request."""
|
82
|
+
headers = {
|
83
|
+
"Authorization": f"Bearer {self.options.api_key}",
|
84
|
+
"Content-Type": "application/json",
|
85
|
+
}
|
86
|
+
|
87
|
+
if is_streaming:
|
88
|
+
headers["Accept"] = "text/event-stream"
|
89
|
+
|
90
|
+
return headers
|
91
|
+
|
92
|
+
def _prepare_content(self, body: Optional[RequestBody]) -> Optional[str]:
|
93
|
+
"""Prepare request content from body."""
|
94
|
+
if not body:
|
95
|
+
return None
|
96
|
+
|
97
|
+
return json.dumps(
|
98
|
+
{
|
99
|
+
**json.loads(body.model_dump_json()),
|
100
|
+
"__internal": {"source": self.options.source},
|
101
|
+
}
|
102
|
+
)
|
103
|
+
|
104
|
+
@asynccontextmanager
|
105
|
+
async def _streaming_request(
|
106
|
+
self,
|
107
|
+
handler: RequestHandler,
|
108
|
+
params: Optional[RequestParams] = None,
|
109
|
+
body: Optional[RequestBody] = None,
|
55
110
|
) -> AsyncGenerator[ClientResponse, Any]:
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
111
|
+
"""Handle streaming requests with proper resource management."""
|
112
|
+
headers = self._prepare_headers(is_streaming=True)
|
113
|
+
content = self._prepare_content(body)
|
114
|
+
method, url = self.router.resolve(handler, params)
|
115
|
+
|
116
|
+
async with httpx.AsyncClient(
|
117
|
+
headers=headers,
|
61
118
|
timeout=self.options.timeout,
|
62
119
|
follow_redirects=False,
|
63
120
|
max_redirects=0,
|
64
|
-
)
|
65
|
-
|
66
|
-
|
121
|
+
) as client:
|
122
|
+
response = await self._execute_with_retry(
|
123
|
+
lambda: client.stream(method=method, url=url, content=content), # type: ignore
|
124
|
+
is_streaming=True,
|
125
|
+
)
|
67
126
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
127
|
+
# For streaming responses, yield the response directly
|
128
|
+
# The caller is responsible for consuming the stream
|
129
|
+
yield response
|
130
|
+
|
131
|
+
@asynccontextmanager
|
132
|
+
async def _non_streaming_request(
|
133
|
+
self,
|
134
|
+
handler: RequestHandler,
|
135
|
+
params: Optional[RequestParams] = None,
|
136
|
+
body: Optional[RequestBody] = None,
|
137
|
+
) -> AsyncGenerator[ClientResponse, Any]:
|
138
|
+
"""Handle non-streaming requests with proper resource management."""
|
139
|
+
headers = self._prepare_headers(is_streaming=False)
|
140
|
+
content = self._prepare_content(body)
|
141
|
+
method, url = self.router.resolve(handler, params)
|
142
|
+
|
143
|
+
async with httpx.AsyncClient(
|
144
|
+
headers=headers,
|
145
|
+
timeout=self.options.timeout,
|
146
|
+
follow_redirects=False,
|
147
|
+
max_redirects=0,
|
148
|
+
) as client:
|
149
|
+
response = await self._execute_with_retry(
|
150
|
+
lambda: client.request(method=method, url=url, content=content), # type: ignore
|
151
|
+
is_streaming=False,
|
152
|
+
)
|
153
|
+
|
154
|
+
try:
|
155
|
+
# Pre-read the response text for non-streaming responses
|
156
|
+
# This ensures the response is fully loaded and validates it
|
157
|
+
_ = response.text
|
158
|
+
yield response
|
159
|
+
finally:
|
160
|
+
await response.aclose()
|
161
|
+
|
162
|
+
async def _execute_with_retry(
|
163
|
+
self,
|
164
|
+
request_func: Callable[[], Awaitable[ClientResponse]],
|
165
|
+
is_streaming: bool = False,
|
166
|
+
) -> ClientResponse:
|
167
|
+
"""Execute a request with retry logic."""
|
168
|
+
last_exception = None
|
169
|
+
last_response = None
|
86
170
|
|
87
|
-
|
88
|
-
|
89
|
-
|
171
|
+
for attempt in range(1, self.options.retries + 1):
|
172
|
+
response_cm = None
|
173
|
+
response = None
|
90
174
|
|
91
|
-
|
92
|
-
|
175
|
+
try:
|
176
|
+
if is_streaming:
|
177
|
+
# For streaming, request_func returns a context manager
|
178
|
+
response_cm = request_func()
|
179
|
+
response = await response_cm.__aenter__() # type: ignore
|
180
|
+
# Store the context manager for proper cleanup
|
181
|
+
response._stream_cm = response_cm # pyright: ignore [reportAttributeAccessIssue]
|
182
|
+
else:
|
183
|
+
response = await request_func()
|
93
184
|
|
94
|
-
|
95
|
-
|
96
|
-
else:
|
97
|
-
raise await self._exception(exception, response) from exception
|
185
|
+
response.raise_for_status() # type: ignore
|
186
|
+
return response # type: ignore
|
98
187
|
|
99
|
-
|
100
|
-
|
101
|
-
await response.aclose()
|
188
|
+
except Exception as exception:
|
189
|
+
last_exception = exception
|
102
190
|
|
103
|
-
|
191
|
+
# For HTTP errors, get the response from the exception
|
192
|
+
if isinstance(exception, httpx.HTTPStatusError):
|
193
|
+
last_response = exception.response
|
194
|
+
else:
|
195
|
+
last_response = getattr(exception, "response", None)
|
104
196
|
|
105
|
-
|
106
|
-
|
107
|
-
|
197
|
+
# Don't retry ApiErrors - they're business logic errors
|
198
|
+
if isinstance(exception, ApiError):
|
199
|
+
raise exception
|
108
200
|
|
109
|
-
|
201
|
+
# If this is the last attempt, break to raise the exception
|
202
|
+
if attempt >= self.options.retries:
|
203
|
+
break
|
204
|
+
|
205
|
+
# Check if we should retry based on status code
|
206
|
+
if (
|
207
|
+
isinstance(exception, httpx.HTTPStatusError)
|
208
|
+
and exception.response.status_code in RETRIABLE_STATUSES
|
209
|
+
):
|
210
|
+
await asyncio.sleep(self._calculate_delay(attempt))
|
211
|
+
continue
|
212
|
+
|
213
|
+
# For non-retriable errors, don't retry
|
214
|
+
break
|
110
215
|
|
111
|
-
|
112
|
-
|
216
|
+
# If we get here, all retries failed
|
217
|
+
raise await self._exception(last_exception, last_response) from last_exception # type: ignore
|
218
|
+
|
219
|
+
def _calculate_delay(self, attempt: int) -> float:
|
220
|
+
"""Calculate exponential backoff delay."""
|
221
|
+
return self.options.delay * (2 ** (attempt - 1))
|
113
222
|
|
114
223
|
async def _exception(self, exception: Exception, response: Optional[httpx.Response] = None) -> ApiError:
|
115
224
|
if not response:
|
@@ -120,14 +229,41 @@ class Client:
|
|
120
229
|
response=str(exception),
|
121
230
|
)
|
122
231
|
|
232
|
+
# Try to safely get response text and content, handling streaming responses
|
233
|
+
response_text = ""
|
234
|
+
response_content = b""
|
235
|
+
|
236
|
+
try:
|
237
|
+
# Check if this is a streaming response
|
238
|
+
content_type = response.headers.get("content-type", "")
|
239
|
+
is_streaming = "text/event-stream" in content_type
|
240
|
+
|
241
|
+
# For streaming responses, try to read content but don't access text
|
242
|
+
if is_streaming:
|
243
|
+
# For streaming responses, we can access content but not text
|
244
|
+
response_content = response.content
|
245
|
+
response_text = ""
|
246
|
+
else:
|
247
|
+
# For non-streaming responses, we can safely access both
|
248
|
+
response_content = response.content
|
249
|
+
response_text = response.text
|
250
|
+
except Exception:
|
251
|
+
# If we can't read the response (e.g., streaming response that hasn't been read),
|
252
|
+
# try to get what we can
|
253
|
+
try:
|
254
|
+
response_content = response.content
|
255
|
+
except Exception:
|
256
|
+
response_content = b""
|
257
|
+
response_text = ""
|
258
|
+
|
123
259
|
try:
|
124
|
-
error = ErrorResponse.model_validate_json(
|
260
|
+
error = ErrorResponse.model_validate_json(response_content)
|
125
261
|
|
126
262
|
return ApiError(
|
127
263
|
status=response.status_code,
|
128
264
|
code=error.code,
|
129
265
|
message=error.message,
|
130
|
-
response=
|
266
|
+
response=response_text,
|
131
267
|
db_ref=ApiErrorDbRef(**dict(error.db_ref)) if error.db_ref else None,
|
132
268
|
)
|
133
269
|
|
@@ -136,5 +272,5 @@ class Client:
|
|
136
272
|
status=response.status_code,
|
137
273
|
code=ApiErrorCodes.InternalServerError,
|
138
274
|
message=str(exception),
|
139
|
-
response=
|
275
|
+
response=response_text,
|
140
276
|
)
|
latitude_sdk/client/payloads.py
CHANGED
@@ -45,6 +45,7 @@ class RunPromptRequestBody(Model):
|
|
45
45
|
parameters: Optional[Dict[str, Any]] = None
|
46
46
|
custom_identifier: Optional[str] = Field(default=None, alias=str("customIdentifier"))
|
47
47
|
stream: Optional[bool] = None
|
48
|
+
tools: Optional[List[str]] = None
|
48
49
|
|
49
50
|
|
50
51
|
class ChatPromptRequestParams(Model):
|
@@ -88,6 +89,16 @@ class AnnotateEvaluationRequestBody(Model):
|
|
88
89
|
metadata: Optional[Metadata] = None
|
89
90
|
|
90
91
|
|
92
|
+
class ToolResultsRequestBody(Model):
|
93
|
+
tool_call_id: str = Field(alias=str("toolCallId"))
|
94
|
+
result: Any
|
95
|
+
is_error: Optional[bool] = Field(default=None, alias=str("isError"))
|
96
|
+
|
97
|
+
|
98
|
+
class CreateProjectRequestBody(Model):
|
99
|
+
name: str
|
100
|
+
|
101
|
+
|
91
102
|
RequestParams = Union[
|
92
103
|
GetPromptRequestParams,
|
93
104
|
GetAllPromptRequestParams,
|
@@ -105,6 +116,8 @@ RequestBody = Union[
|
|
105
116
|
ChatPromptRequestBody,
|
106
117
|
CreateLogRequestBody,
|
107
118
|
AnnotateEvaluationRequestBody,
|
119
|
+
ToolResultsRequestBody,
|
120
|
+
CreateProjectRequestBody,
|
108
121
|
]
|
109
122
|
|
110
123
|
|
@@ -116,3 +129,6 @@ class RequestHandler(StrEnum):
|
|
116
129
|
ChatPrompt = "CHAT_PROMPT"
|
117
130
|
CreateLog = "CREATE_LOG"
|
118
131
|
AnnotateEvaluation = "ANNOTATE_EVALUATION"
|
132
|
+
ToolResults = "TOOL_RESULTS"
|
133
|
+
GetAllProjects = "GET_ALL_PROJECTS"
|
134
|
+
CreateProject = "CREATE_PROJECT"
|
latitude_sdk/client/router.py
CHANGED
@@ -27,7 +27,7 @@ class Router:
|
|
27
27
|
def __init__(self, options: RouterOptions):
|
28
28
|
self.options = options
|
29
29
|
|
30
|
-
def resolve(self, handler: RequestHandler, params: RequestParams) -> Tuple[str, str]:
|
30
|
+
def resolve(self, handler: RequestHandler, params: Optional[RequestParams] = None) -> Tuple[str, str]:
|
31
31
|
if handler == RequestHandler.GetPrompt:
|
32
32
|
assert isinstance(params, GetPromptRequestParams)
|
33
33
|
|
@@ -90,6 +90,15 @@ class Router:
|
|
90
90
|
|
91
91
|
return "POST", self.conversations().annotate(params.conversation_uuid, params.evaluation_uuid)
|
92
92
|
|
93
|
+
elif handler == RequestHandler.ToolResults:
|
94
|
+
return "POST", f"{self.options.gateway.base_url}/tools/results"
|
95
|
+
|
96
|
+
elif handler == RequestHandler.GetAllProjects:
|
97
|
+
return "GET", f"{self.options.gateway.base_url}/projects"
|
98
|
+
|
99
|
+
elif handler == RequestHandler.CreateProject:
|
100
|
+
return "POST", f"{self.options.gateway.base_url}/projects"
|
101
|
+
|
93
102
|
raise TypeError(f"Unknown handler: {handler}")
|
94
103
|
|
95
104
|
class Conversations(Model):
|
latitude_sdk/sdk/latitude.py
CHANGED
@@ -6,6 +6,7 @@ from latitude_sdk.client import Client, ClientOptions, RouterOptions
|
|
6
6
|
from latitude_sdk.env import env
|
7
7
|
from latitude_sdk.sdk.evaluations import Evaluations
|
8
8
|
from latitude_sdk.sdk.logs import Logs
|
9
|
+
from latitude_sdk.sdk.projects import Projects
|
9
10
|
from latitude_sdk.sdk.prompts import Prompts
|
10
11
|
from latitude_sdk.sdk.types import GatewayOptions, LogSources, SdkOptions
|
11
12
|
from latitude_sdk.util import Model
|
@@ -49,6 +50,7 @@ class Latitude:
|
|
49
50
|
|
50
51
|
promptl: Promptl
|
51
52
|
|
53
|
+
projects: Projects
|
52
54
|
prompts: Prompts
|
53
55
|
logs: Logs
|
54
56
|
evaluations: Evaluations
|
@@ -76,6 +78,7 @@ class Latitude:
|
|
76
78
|
)
|
77
79
|
|
78
80
|
self.promptl = Promptl(self._options.promptl)
|
81
|
+
self.projects = Projects(self._client, self._options)
|
79
82
|
self.prompts = Prompts(self._client, self.promptl, self._options)
|
80
83
|
self.logs = Logs(self._client, self._options)
|
81
84
|
self.evaluations = Evaluations(self._client, self._options)
|
@@ -0,0 +1,41 @@
|
|
1
|
+
import json
|
2
|
+
from typing import List
|
3
|
+
|
4
|
+
from latitude_sdk.client import Client
|
5
|
+
from latitude_sdk.client.payloads import CreateProjectRequestBody, RequestHandler
|
6
|
+
from latitude_sdk.sdk.types import Project, SdkOptions, Version
|
7
|
+
|
8
|
+
|
9
|
+
class CreateProjectResponse:
|
10
|
+
def __init__(self, project: Project, version: Version):
|
11
|
+
self.project = project
|
12
|
+
self.version = version
|
13
|
+
|
14
|
+
|
15
|
+
class Projects:
|
16
|
+
_client: Client
|
17
|
+
_options: SdkOptions
|
18
|
+
|
19
|
+
def __init__(self, client: Client, options: SdkOptions):
|
20
|
+
self._client = client
|
21
|
+
self._options = options
|
22
|
+
|
23
|
+
async def get_all(self) -> List[Project]:
|
24
|
+
async with self._client.request(
|
25
|
+
handler=RequestHandler.GetAllProjects,
|
26
|
+
params=None,
|
27
|
+
) as response:
|
28
|
+
projects_data = json.loads(response.content)
|
29
|
+
return [Project.model_validate_json(json.dumps(project)) for project in projects_data]
|
30
|
+
|
31
|
+
async def create(self, name: str) -> CreateProjectResponse:
|
32
|
+
async with self._client.request(
|
33
|
+
handler=RequestHandler.CreateProject,
|
34
|
+
params=None,
|
35
|
+
body=CreateProjectRequestBody(name=name),
|
36
|
+
) as response:
|
37
|
+
response_data = json.loads(response.content)
|
38
|
+
return CreateProjectResponse(
|
39
|
+
project=Project.model_validate_json(json.dumps(response_data["project"])),
|
40
|
+
version=Version.model_validate_json(json.dumps(response_data["version"])),
|
41
|
+
)
|
latitude_sdk/sdk/prompts.py
CHANGED
@@ -1,7 +1,11 @@
|
|
1
|
-
import
|
2
|
-
from typing import Any, AsyncGenerator, List, Optional, Sequence, Tuple, Union
|
1
|
+
from typing import Any, AsyncGenerator, Callable, List, Optional, Sequence
|
3
2
|
|
4
|
-
from promptl_ai import
|
3
|
+
from promptl_ai import (
|
4
|
+
Adapter,
|
5
|
+
Message,
|
6
|
+
MessageLike,
|
7
|
+
Promptl,
|
8
|
+
)
|
5
9
|
from promptl_ai.bindings.types import _Message
|
6
10
|
|
7
11
|
from latitude_sdk.client import (
|
@@ -16,10 +20,10 @@ from latitude_sdk.client import (
|
|
16
20
|
RequestHandler,
|
17
21
|
RunPromptRequestBody,
|
18
22
|
RunPromptRequestParams,
|
23
|
+
ToolResultsRequestBody,
|
19
24
|
)
|
20
25
|
from latitude_sdk.sdk.errors import ApiError, ApiErrorCodes
|
21
26
|
from latitude_sdk.sdk.types import (
|
22
|
-
AGENT_END_TOOL_NAME,
|
23
27
|
ChainEvents,
|
24
28
|
FinishedResult,
|
25
29
|
OnStep,
|
@@ -30,7 +34,6 @@ from latitude_sdk.sdk.types import (
|
|
30
34
|
SdkOptions,
|
31
35
|
StreamCallbacks,
|
32
36
|
StreamEvents,
|
33
|
-
ToolCall,
|
34
37
|
ToolResult,
|
35
38
|
_LatitudeEvent,
|
36
39
|
)
|
@@ -52,10 +55,6 @@ _PROMPT_ATTR_TO_ADAPTER_ATTR = {
|
|
52
55
|
}
|
53
56
|
|
54
57
|
|
55
|
-
class OnToolCallPaused(Exception):
|
56
|
-
pass
|
57
|
-
|
58
|
-
|
59
58
|
class PromptOptions(Model):
|
60
59
|
project_id: Optional[int] = None
|
61
60
|
version_uuid: Optional[str] = None
|
@@ -142,28 +141,15 @@ class Prompts:
|
|
142
141
|
response="Project ID is required",
|
143
142
|
)
|
144
143
|
|
145
|
-
async def _extract_agent_tool_requests(
|
146
|
-
self, tool_requests: List[ToolCall]
|
147
|
-
) -> Tuple[List[ToolCall], List[ToolCall]]:
|
148
|
-
agent: List[ToolCall] = []
|
149
|
-
other: List[ToolCall] = []
|
150
|
-
|
151
|
-
for tool in tool_requests:
|
152
|
-
if tool.name == AGENT_END_TOOL_NAME:
|
153
|
-
agent.append(tool)
|
154
|
-
else:
|
155
|
-
other.append(tool)
|
156
|
-
|
157
|
-
return agent, other
|
158
|
-
|
159
144
|
async def _handle_stream(
|
160
|
-
self,
|
145
|
+
self,
|
146
|
+
stream: AsyncGenerator[ClientEvent, Any],
|
147
|
+
on_event: Optional[StreamCallbacks.OnEvent],
|
148
|
+
on_tool_call: Optional[Callable[[dict[str, Any]], Any]] = None,
|
161
149
|
) -> FinishedResult:
|
162
150
|
uuid = None
|
163
151
|
conversation: List[Message] = []
|
164
152
|
response = None
|
165
|
-
agent_response = None
|
166
|
-
tool_requests: List[ToolCall] = []
|
167
153
|
|
168
154
|
async for stream_event in stream:
|
169
155
|
event = None
|
@@ -176,9 +162,6 @@ class Prompts:
|
|
176
162
|
if event.type == ChainEvents.ProviderCompleted:
|
177
163
|
response = event.response
|
178
164
|
|
179
|
-
elif event.type == ChainEvents.ToolsRequested:
|
180
|
-
tool_requests = event.tools
|
181
|
-
|
182
165
|
elif event.type == ChainEvents.ChainError:
|
183
166
|
raise ApiError(
|
184
167
|
status=400,
|
@@ -191,6 +174,10 @@ class Prompts:
|
|
191
174
|
event = stream_event.json()
|
192
175
|
event["event"] = StreamEvents.Provider
|
193
176
|
|
177
|
+
# Handle tool calls when received in the stream
|
178
|
+
if on_tool_call and event.get("type") == "tool-call":
|
179
|
+
await on_tool_call(event)
|
180
|
+
|
194
181
|
else:
|
195
182
|
raise ApiError(
|
196
183
|
status=500,
|
@@ -210,22 +197,12 @@ class Prompts:
|
|
210
197
|
response="Stream ended without a chain-complete event. Missing uuid or response.",
|
211
198
|
)
|
212
199
|
|
213
|
-
agent_requests, tool_requests = await self._extract_agent_tool_requests(tool_requests)
|
214
|
-
if len(agent_requests) > 0:
|
215
|
-
agent_response = agent_requests[0].arguments
|
216
|
-
|
217
200
|
return FinishedResult(
|
218
201
|
uuid=uuid,
|
219
202
|
conversation=conversation,
|
220
203
|
response=response,
|
221
|
-
agent_response=agent_response,
|
222
|
-
tool_requests=tool_requests,
|
223
204
|
)
|
224
205
|
|
225
|
-
@staticmethod
|
226
|
-
def _pause_tool_execution() -> Any:
|
227
|
-
raise OnToolCallPaused()
|
228
|
-
|
229
206
|
@staticmethod
|
230
207
|
async def _wrap_tool_handler(
|
231
208
|
handler: OnToolCall, arguments: dict[str, Any], details: OnToolCallDetails
|
@@ -237,67 +214,64 @@ class Prompts:
|
|
237
214
|
|
238
215
|
return ToolResult(**tool_result, result=result)
|
239
216
|
except Exception as exception:
|
240
|
-
if isinstance(exception, OnToolCallPaused):
|
241
|
-
raise exception
|
242
|
-
|
243
217
|
return ToolResult(**tool_result, result=str(exception), is_error=True)
|
244
218
|
|
245
|
-
async def
|
246
|
-
self,
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
219
|
+
async def _handle_tool_call(
|
220
|
+
self,
|
221
|
+
event: dict[str, Any],
|
222
|
+
tools: dict[str, OnToolCall],
|
223
|
+
) -> None:
|
224
|
+
toolCallId: str = event["toolCallId"]
|
225
|
+
toolName: str = event["toolName"]
|
226
|
+
args: dict[str, Any] = event["args"]
|
227
|
+
|
228
|
+
tool = tools.get(toolName)
|
229
|
+
if not tool:
|
230
|
+
return
|
231
|
+
|
232
|
+
tool_result = await self._wrap_tool_handler(
|
233
|
+
tool,
|
234
|
+
args,
|
235
|
+
OnToolCallDetails(
|
236
|
+
id=toolCallId,
|
237
|
+
name=toolName,
|
238
|
+
arguments=args,
|
239
|
+
),
|
240
|
+
)
|
264
241
|
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
242
|
+
try:
|
243
|
+
async with self._client.request(
|
244
|
+
handler=RequestHandler.ToolResults,
|
245
|
+
params=None,
|
246
|
+
body=ToolResultsRequestBody(
|
247
|
+
tool_call_id=toolCallId,
|
248
|
+
result=tool_result.result,
|
249
|
+
is_error=tool_result.is_error,
|
250
|
+
),
|
251
|
+
) as _:
|
252
|
+
pass
|
253
|
+
except Exception as exception:
|
254
|
+
if not isinstance(exception, ApiError):
|
255
|
+
exception = ApiError(
|
256
|
+
status=500,
|
257
|
+
code=ApiErrorCodes.InternalServerError,
|
258
|
+
message=str(exception),
|
259
|
+
response=str(exception),
|
278
260
|
)
|
279
|
-
for tool_call in result.tool_requests
|
280
|
-
],
|
281
|
-
return_exceptions=False,
|
282
|
-
)
|
283
261
|
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
ToolResultContent(
|
288
|
-
id=tool_result.id,
|
289
|
-
name=tool_result.name,
|
290
|
-
result=tool_result.result,
|
291
|
-
is_error=tool_result.is_error,
|
292
|
-
)
|
293
|
-
]
|
294
|
-
)
|
295
|
-
for tool_result in tool_results
|
296
|
-
]
|
262
|
+
# Add context about which tool failed
|
263
|
+
message = f"Failed to execute tool {toolName}. \nLatitude API returned the following error:\
|
264
|
+
\n\n{exception.message}"
|
297
265
|
|
298
|
-
|
266
|
+
raise ApiError(
|
267
|
+
status=exception.status,
|
268
|
+
code=exception.code,
|
269
|
+
message=message,
|
270
|
+
response=exception.response,
|
271
|
+
) from exception
|
299
272
|
|
300
|
-
|
273
|
+
def _on_tool_call(self, tools: dict[str, OnToolCall]) -> Callable[[dict[str, Any]], Any]:
|
274
|
+
return lambda event: self._handle_tool_call(event, tools)
|
301
275
|
|
302
276
|
async def get(self, path: str, options: Optional[GetPromptOptions] = None) -> GetPromptResult:
|
303
277
|
options = GetPromptOptions(**{**dict(self._options), **dict(options or {})})
|
@@ -365,21 +339,18 @@ class Prompts:
|
|
365
339
|
parameters=options.parameters,
|
366
340
|
custom_identifier=options.custom_identifier,
|
367
341
|
stream=options.stream,
|
342
|
+
tools=list(options.tools.keys()) if options.tools and options.stream else None,
|
368
343
|
),
|
369
344
|
) as response:
|
370
345
|
if options.stream:
|
371
|
-
result = await self._handle_stream(
|
346
|
+
result = await self._handle_stream(
|
347
|
+
response.sse(),
|
348
|
+
options.on_event,
|
349
|
+
self._on_tool_call(options.tools if options.tools else {}),
|
350
|
+
)
|
372
351
|
else:
|
373
352
|
result = RunPromptResult.model_validate_json(response.content)
|
374
353
|
|
375
|
-
if options.tools and result.tool_requests:
|
376
|
-
try:
|
377
|
-
# NOTE: The last sdk.chat called will already call on_finished
|
378
|
-
final_result = await self._handle_tool_calls(result, options)
|
379
|
-
return RunPromptResult(**dict(final_result)) if final_result else None
|
380
|
-
except OnToolCallPaused:
|
381
|
-
pass
|
382
|
-
|
383
354
|
if options.on_finished:
|
384
355
|
options.on_finished(FinishedResult(**dict(result)))
|
385
356
|
|
@@ -402,7 +373,10 @@ class Prompts:
|
|
402
373
|
return None
|
403
374
|
|
404
375
|
async def chat(
|
405
|
-
self,
|
376
|
+
self,
|
377
|
+
uuid: str,
|
378
|
+
messages: Sequence[MessageLike],
|
379
|
+
options: Optional[ChatPromptOptions] = None,
|
406
380
|
) -> Optional[ChatPromptResult]:
|
407
381
|
options = ChatPromptOptions(**{**dict(self._options), **dict(options or {})})
|
408
382
|
|
@@ -420,18 +394,14 @@ class Prompts:
|
|
420
394
|
),
|
421
395
|
) as response:
|
422
396
|
if options.stream:
|
423
|
-
result = await self._handle_stream(
|
397
|
+
result = await self._handle_stream(
|
398
|
+
response.sse(),
|
399
|
+
options.on_event,
|
400
|
+
self._on_tool_call(options.tools if options.tools else {}),
|
401
|
+
)
|
424
402
|
else:
|
425
403
|
result = ChatPromptResult.model_validate_json(response.content)
|
426
404
|
|
427
|
-
if options.tools and result.tool_requests:
|
428
|
-
try:
|
429
|
-
# NOTE: The last sdk.chat called will already call on_finished
|
430
|
-
final_result = await self._handle_tool_calls(result, options)
|
431
|
-
return ChatPromptResult(**dict(final_result)) if final_result else None
|
432
|
-
except OnToolCallPaused:
|
433
|
-
pass
|
434
|
-
|
435
405
|
if options.on_finished:
|
436
406
|
options.on_finished(FinishedResult(**dict(result)))
|
437
407
|
|
@@ -481,7 +451,10 @@ class Prompts:
|
|
481
451
|
)
|
482
452
|
|
483
453
|
async def render_chain(
|
484
|
-
self,
|
454
|
+
self,
|
455
|
+
prompt: Prompt,
|
456
|
+
on_step: OnStep,
|
457
|
+
options: Optional[RenderChainOptions] = None,
|
485
458
|
) -> RenderChainResult:
|
486
459
|
options = RenderChainOptions(**{**dict(self._options), **dict(options or {})})
|
487
460
|
adapter = options.adapter or _PROVIDER_TO_ADAPTER.get(prompt.provider or Providers.OpenAI, Adapter.OpenAI)
|
latitude_sdk/sdk/types.py
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
from datetime import datetime
|
2
2
|
from typing import (
|
3
3
|
Any,
|
4
|
-
Callable,
|
5
4
|
List,
|
6
5
|
Literal,
|
7
6
|
Optional,
|
@@ -70,7 +69,6 @@ class FinishReason(StrEnum):
|
|
70
69
|
|
71
70
|
|
72
71
|
AGENT_START_TOOL_NAME = "start_autonomous_chain"
|
73
|
-
AGENT_END_TOOL_NAME = "end_autonomous_chain"
|
74
72
|
|
75
73
|
|
76
74
|
class ToolCall(Model):
|
@@ -131,7 +129,6 @@ class ChainEvents(StrEnum):
|
|
131
129
|
StepCompleted = "step-completed"
|
132
130
|
ChainCompleted = "chain-completed"
|
133
131
|
ChainError = "chain-error"
|
134
|
-
ToolsRequested = "tools-requested"
|
135
132
|
|
136
133
|
|
137
134
|
class GenericChainEvent(Model):
|
@@ -185,11 +182,6 @@ class ChainEventChainError(GenericChainEvent):
|
|
185
182
|
error: ChainError
|
186
183
|
|
187
184
|
|
188
|
-
class ChainEventToolsRequested(GenericChainEvent):
|
189
|
-
type: Literal[ChainEvents.ToolsRequested] = ChainEvents.ToolsRequested
|
190
|
-
tools: List[ToolCall]
|
191
|
-
|
192
|
-
|
193
185
|
ChainEvent = Union[
|
194
186
|
ChainEventChainStarted,
|
195
187
|
ChainEventStepStarted,
|
@@ -200,7 +192,6 @@ ChainEvent = Union[
|
|
200
192
|
ChainEventStepCompleted,
|
201
193
|
ChainEventChainCompleted,
|
202
194
|
ChainEventChainError,
|
203
|
-
ChainEventToolsRequested,
|
204
195
|
]
|
205
196
|
|
206
197
|
LatitudeEvent = ChainEvent
|
@@ -211,8 +202,6 @@ class FinishedResult(Model):
|
|
211
202
|
uuid: str
|
212
203
|
conversation: List[Message]
|
213
204
|
response: ChainResponse
|
214
|
-
agent_response: Optional[dict[str, Any]] = Field(default=None, alias=str("agentResponse"))
|
215
|
-
tool_requests: List[ToolCall] = Field(alias=str("toolRequests"))
|
216
205
|
|
217
206
|
|
218
207
|
StreamEvent = Union[ProviderEvent, LatitudeEvent]
|
@@ -240,6 +229,22 @@ class Log(Model):
|
|
240
229
|
updated_at: datetime = Field(alias=str("updatedAt"))
|
241
230
|
|
242
231
|
|
232
|
+
class Project(Model):
|
233
|
+
id: int
|
234
|
+
uuid: Optional[str] = None
|
235
|
+
name: str
|
236
|
+
created_at: datetime = Field(alias=str("createdAt"))
|
237
|
+
updated_at: datetime = Field(alias=str("updatedAt"))
|
238
|
+
|
239
|
+
|
240
|
+
class Version(Model):
|
241
|
+
uuid: str
|
242
|
+
name: str
|
243
|
+
project_id: int = Field(alias=str("projectId"))
|
244
|
+
created_at: datetime = Field(alias=str("createdAt"))
|
245
|
+
updated_at: datetime = Field(alias=str("updatedAt"))
|
246
|
+
|
247
|
+
|
243
248
|
class StreamCallbacks(Model):
|
244
249
|
@runtime_checkable
|
245
250
|
class OnEvent(Protocol):
|
@@ -263,10 +268,7 @@ class StreamCallbacks(Model):
|
|
263
268
|
class OnToolCallDetails(Model):
|
264
269
|
id: str
|
265
270
|
name: str
|
266
|
-
|
267
|
-
messages: List[Message]
|
268
|
-
pause_execution: Callable[[], ToolResult]
|
269
|
-
requested_tool_calls: List[ToolCall]
|
271
|
+
arguments: dict[str, Any]
|
270
272
|
|
271
273
|
|
272
274
|
@runtime_checkable
|
@@ -1,13 +1,14 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: latitude-sdk
|
3
|
-
Version:
|
3
|
+
Version: 4.0.0b1
|
4
4
|
Summary: Latitude SDK for Python
|
5
5
|
Project-URL: repository, https://github.com/latitude-dev/latitude-llm/tree/main/packages/sdks/python
|
6
6
|
Project-URL: homepage, https://github.com/latitude-dev/latitude-llm/tree/main/packages/sdks/python#readme
|
7
7
|
Project-URL: documentation, https://github.com/latitude-dev/latitude-llm/tree/main/packages/sdks/python#readme
|
8
8
|
Author-email: Latitude Data SL <hello@latitude.so>
|
9
9
|
Maintainer-email: Latitude Data SL <hello@latitude.so>
|
10
|
-
License-Expression:
|
10
|
+
License-Expression: MIT
|
11
|
+
License-File: LICENSE.md
|
11
12
|
Requires-Python: <3.13,>=3.9
|
12
13
|
Requires-Dist: httpx-sse>=0.4.0
|
13
14
|
Requires-Dist: httpx>=0.28.1
|
@@ -45,7 +46,7 @@ await sdk.prompts.run("joke-teller", RunPromptOptions(
|
|
45
46
|
))
|
46
47
|
```
|
47
48
|
|
48
|
-
Find more [examples](https://
|
49
|
+
Find more [examples](https://docs.latitude.so/examples/sdk).
|
49
50
|
|
50
51
|
## Development
|
51
52
|
|
@@ -59,7 +60,9 @@ Requires uv `0.5.10` or higher.
|
|
59
60
|
- Build package: `uv build`
|
60
61
|
- Publish package: `uv publish`
|
61
62
|
|
62
|
-
|
63
|
+
### Running only a specific test
|
64
|
+
|
65
|
+
Mark the test with an `only` marker:
|
63
66
|
|
64
67
|
```python
|
65
68
|
import pytest
|
@@ -69,13 +72,13 @@ async def my_test(self):
|
|
69
72
|
# ... your code
|
70
73
|
```
|
71
74
|
|
72
|
-
|
75
|
+
...and then run the tests with the marker `only`:
|
73
76
|
|
74
77
|
```sh
|
75
78
|
uv run scripts/test.py -m only
|
76
79
|
```
|
77
80
|
|
78
|
-
|
81
|
+
Another way is to specify the test in line:
|
79
82
|
|
80
83
|
```python
|
81
84
|
uv run scripts/test.py <test_path>::<test_case>::<test_name>
|
@@ -83,4 +86,4 @@ uv run scripts/test.py <test_path>::<test_case>::<test_name>
|
|
83
86
|
|
84
87
|
## License
|
85
88
|
|
86
|
-
The SDK is licensed under the [
|
89
|
+
The SDK is licensed under the [MIT License](https://opensource.org/licenses/MIT) - read the [LICENSE](/LICENSE) file for details.
|
@@ -0,0 +1,22 @@
|
|
1
|
+
latitude_sdk/__init__.py,sha256=-AbNXLmzDZeGbRdDIOpNjdCbacOvLBflSJwQtLlZfgk,19
|
2
|
+
latitude_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
+
latitude_sdk/client/__init__.py,sha256=d8CnNB8UoGwcftiIeeC0twdg01qNvfpj-v7O40I7IiE,68
|
4
|
+
latitude_sdk/client/client.py,sha256=PR89Qy0hL0GQEEKs4eys7wc3BhIPF_P9N-AJB6ufrV8,9676
|
5
|
+
latitude_sdk/client/payloads.py,sha256=z0tLYE9clybYaFWHgMVJgj9w-6exUQgfdB8egLw1-5E,2998
|
6
|
+
latitude_sdk/client/router.py,sha256=k5OT42_3riJlTm5Q2BU3AplmpN1FdMxQeIj8GptcllQ,4616
|
7
|
+
latitude_sdk/env/__init__.py,sha256=66of5veJ-u1aNI025L65Rrj321AjrYevMqomTMYIrPQ,19
|
8
|
+
latitude_sdk/env/env.py,sha256=MnXexPOHE6aXcAszrDCbW7hzACUv4YtU1bfxpYwvHNw,455
|
9
|
+
latitude_sdk/sdk/__init__.py,sha256=C9LlIjfnrS7KOK3-ruXKmbT77nSQMm23nZ6-t8sO8ME,137
|
10
|
+
latitude_sdk/sdk/errors.py,sha256=9GlGdDE8LGy3dE2Ry_BipBg-tDbQx7LWXJfSnTJSSBE,1747
|
11
|
+
latitude_sdk/sdk/evaluations.py,sha256=UP0DKMOLbqcCYrJcxiqgsqUM3anGs8pkAZu1DoOSwxI,1845
|
12
|
+
latitude_sdk/sdk/latitude.py,sha256=-569RfclTdfjuEIrbz0y6mH3o3LF7KAAWqphLPBN0dU,2702
|
13
|
+
latitude_sdk/sdk/logs.py,sha256=CyHkRJvPl_p7wTSvR9bgxEI5akS0Tjc9FeQRb2C2vMg,1997
|
14
|
+
latitude_sdk/sdk/projects.py,sha256=1l6Vvu01-Oi7tp2L1lAhWyZelWIqJVju6rJlR91MVCQ,1458
|
15
|
+
latitude_sdk/sdk/prompts.py,sha256=pi2eAYssCcc6q6kwfzyYfV4z2gRoN_eAS1rXYEAobd8,15949
|
16
|
+
latitude_sdk/sdk/types.py,sha256=lfSjZUrPed8tNHeJ4vi0HLvpWHFthN9MFCKWGcNY-vg,7481
|
17
|
+
latitude_sdk/util/__init__.py,sha256=alIDGBnxWH4JvP-UW-7N99seBBi0r1GV1h8f1ERFBec,21
|
18
|
+
latitude_sdk/util/utils.py,sha256=hMOmF-u1QaDgOwXN6ME6n4TaQ70yZKLvijDUqNCMwXI,2844
|
19
|
+
latitude_sdk-4.0.0b1.dist-info/METADATA,sha256=o9eFEHQ7z5h7c-A5qpHwwbUDR6pLhO7Bhs-Gpo0u2tA,2372
|
20
|
+
latitude_sdk-4.0.0b1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
21
|
+
latitude_sdk-4.0.0b1.dist-info/licenses/LICENSE.md,sha256=yFReu_tr5pjxslWkQREfxA9yVm2r6gay2s6SFCh0XfQ,1073
|
22
|
+
latitude_sdk-4.0.0b1.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) Latitude Data SL 2025
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -1,20 +0,0 @@
|
|
1
|
-
latitude_sdk/__init__.py,sha256=-AbNXLmzDZeGbRdDIOpNjdCbacOvLBflSJwQtLlZfgk,19
|
2
|
-
latitude_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
-
latitude_sdk/client/__init__.py,sha256=d8CnNB8UoGwcftiIeeC0twdg01qNvfpj-v7O40I7IiE,68
|
4
|
-
latitude_sdk/client/client.py,sha256=Tlg0IU7_64O9yo0BfQEdVwVsjp-5_DUwJZqlX3uEb0w,4399
|
5
|
-
latitude_sdk/client/payloads.py,sha256=XiGadgaZhCMKa2auBPLln6RTEt8RwEqx_H8FoSv5h2c,2554
|
6
|
-
latitude_sdk/client/router.py,sha256=sdNvUNXqoM5TDmvN7toyJWuI8Lt4Z6MhKmWWKitEFes,4218
|
7
|
-
latitude_sdk/env/__init__.py,sha256=66of5veJ-u1aNI025L65Rrj321AjrYevMqomTMYIrPQ,19
|
8
|
-
latitude_sdk/env/env.py,sha256=MnXexPOHE6aXcAszrDCbW7hzACUv4YtU1bfxpYwvHNw,455
|
9
|
-
latitude_sdk/sdk/__init__.py,sha256=C9LlIjfnrS7KOK3-ruXKmbT77nSQMm23nZ6-t8sO8ME,137
|
10
|
-
latitude_sdk/sdk/errors.py,sha256=9GlGdDE8LGy3dE2Ry_BipBg-tDbQx7LWXJfSnTJSSBE,1747
|
11
|
-
latitude_sdk/sdk/evaluations.py,sha256=UP0DKMOLbqcCYrJcxiqgsqUM3anGs8pkAZu1DoOSwxI,1845
|
12
|
-
latitude_sdk/sdk/latitude.py,sha256=lUlGOiZXSFt0zm3sTHfBgjKzbcVJueMk6MXTGn9WCn8,2570
|
13
|
-
latitude_sdk/sdk/logs.py,sha256=CyHkRJvPl_p7wTSvR9bgxEI5akS0Tjc9FeQRb2C2vMg,1997
|
14
|
-
latitude_sdk/sdk/prompts.py,sha256=WztCpDSt0mxng0N2hyIrrzEw1TanqGRIl7Cpc_5ei4M,17369
|
15
|
-
latitude_sdk/sdk/types.py,sha256=e_AQ6YGRNgYvPJOxD5rndH5zPtslKMBhBtLocWlIHQE,7626
|
16
|
-
latitude_sdk/util/__init__.py,sha256=alIDGBnxWH4JvP-UW-7N99seBBi0r1GV1h8f1ERFBec,21
|
17
|
-
latitude_sdk/util/utils.py,sha256=hMOmF-u1QaDgOwXN6ME6n4TaQ70yZKLvijDUqNCMwXI,2844
|
18
|
-
latitude_sdk-3.0.1.dist-info/METADATA,sha256=9z1D0tOFBqpvUZl89T75YQdbJZqbkhVDI3VPlT4yhZg,2327
|
19
|
-
latitude_sdk-3.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
20
|
-
latitude_sdk-3.0.1.dist-info/RECORD,,
|
File without changes
|